Switch to surviving workspace when archiving active thread's worktree

Richard Feldman created

When the user archives the only thread in a linked-worktree workspace,
the sidebar now detects that the workspace's roots are about to be
deleted and switches to a surviving workspace (e.g. the main repo)
instead of staying on the doomed workspace.

Previously the sidebar would set active_entry to the worktree workspace
and open a new thread there, then the async cleanup would delete the
worktree out from under it, leaving the user stranded.

The fix uses the ArchiveOutcome.roots_to_delete returned by
archive_thread to check whether each candidate workspace will lose all
its roots. If so, it finds a surviving workspace in the same window.
This also activates the surviving workspace in the MultiWorkspace so the
user actually sees the switch.

Change summary

crates/sidebar/src/sidebar.rs       |  55 +++++++++++
crates/sidebar/src/sidebar_tests.rs | 143 ++++++++++++++++++++++++++++++
2 files changed, 195 insertions(+), 3 deletions(-)

Detailed changes

crates/sidebar/src/sidebar.rs 🔗

@@ -2458,7 +2458,7 @@ impl Sidebar {
         else {
             return;
         };
-        thread_worktree_archive::archive_thread(
+        let outcome = thread_worktree_archive::archive_thread(
             session_id,
             current_workspace,
             multi_workspace_handle,
@@ -2493,6 +2493,23 @@ impl Sidebar {
                 self.workspace_for_group(path_list, cx)
             });
 
+            // If the group's workspace is about to lose all its roots, find a
+            // surviving workspace in the same window instead.
+            let safe_workspace = if group_workspace
+                .as_ref()
+                .is_some_and(|ws| workspace_will_be_empty(ws, &outcome.roots_to_delete, cx))
+            {
+                self.multi_workspace.upgrade().and_then(|mw| {
+                    let mw = mw.read(cx);
+                    mw.workspaces()
+                        .iter()
+                        .find(|ws| !workspace_will_be_empty(ws, &outcome.roots_to_delete, cx))
+                        .cloned()
+                })
+            } else {
+                group_workspace.clone()
+            };
+
             let next_thread = current_pos.and_then(|pos| {
                 let group_start = self.contents.entries[..pos]
                     .iter()
@@ -2534,10 +2551,21 @@ impl Sidebar {
                 // but belongs to its own workspace). Loading into the wrong panel binds
                 // the thread to the wrong project, which corrupts its stored folder_paths
                 // when metadata is saved via ThreadMetadata::from_thread.
-                let target_workspace = match &next.workspace {
+                let mut target_workspace = match &next.workspace {
                     ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
                     ThreadEntryWorkspace::Closed(_) => group_workspace,
                 };
+                // If the next thread's workspace is also being deleted, fall
+                // back to a surviving workspace.
+                if target_workspace
+                    .as_ref()
+                    .is_some_and(|ws| workspace_will_be_empty(ws, &outcome.roots_to_delete, cx))
+                {
+                    #[allow(clippy::redundant_clone)]
+                    {
+                        target_workspace = safe_workspace.clone();
+                    }
+                }
                 if let Some(ref ws) = target_workspace {
                     self.active_entry = Some(ActiveEntry::Thread {
                         session_id: next_metadata.session_id.clone(),
@@ -2562,8 +2590,14 @@ impl Sidebar {
                     }
                 }
             } else {
-                if let Some(workspace) = &group_workspace {
+                let fallback = safe_workspace.or(group_workspace);
+                if let Some(workspace) = &fallback {
                     self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
+                    if let Some(multi_workspace) = self.multi_workspace.upgrade() {
+                        multi_workspace.update(cx, |mw, cx| {
+                            mw.activate(workspace.clone(), window, cx);
+                        });
+                    }
                     if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
                         agent_panel.update(cx, |panel, cx| {
                             panel.new_thread(&NewThread, window, cx);
@@ -3871,6 +3905,21 @@ impl Render for Sidebar {
     }
 }
 
+fn workspace_will_be_empty(
+    workspace: &Entity<Workspace>,
+    roots_to_delete: &[PathBuf],
+    cx: &App,
+) -> bool {
+    if roots_to_delete.is_empty() {
+        return false;
+    }
+    let root_paths = workspace.read(cx).root_paths(cx);
+    !root_paths.is_empty()
+        && root_paths
+            .iter()
+            .all(|path| roots_to_delete.iter().any(|d| d.as_path() == path.as_ref()))
+}
+
 fn all_thread_infos_for_workspace(
     workspace: &Entity<Workspace>,
     cx: &App,

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -5419,3 +5419,146 @@ mod property_test {
         }
     }
 }
+
+#[gpui::test]
+async fn test_archive_only_thread_in_worktree_switches_to_surviving_workspace(
+    cx: &mut TestAppContext,
+) {
+    // When the user archives the only thread in a linked-worktree workspace,
+    // the sidebar should switch to a surviving workspace (the main repo)
+    // instead of staying on the doomed worktree workspace.
+    agent_ui::test_support::init_test(cx);
+    cx.update(|cx| {
+        cx.update_flags(false, vec!["agent-v2".into()]);
+        ThreadStore::init_global(cx);
+        ThreadMetadataStore::init_global(cx);
+        language_model::LanguageModelRegistry::test(cx);
+        prompt_store::init(cx);
+    });
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/project",
+        serde_json::json!({
+            ".git": {},
+            "src": {},
+        }),
+    )
+    .await;
+
+    fs.add_linked_worktree_for_repo(
+        Path::new("/project/.git"),
+        false,
+        git::repository::Worktree {
+            path: PathBuf::from("/wt-feature"),
+            ref_name: Some("refs/heads/feature".into()),
+            sha: "aaa".into(),
+            is_main: false,
+        },
+    )
+    .await;
+
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+    let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
+    let worktree_project = project::Project::test(fs.clone(), ["/wt-feature".as_ref()], cx).await;
+
+    main_project
+        .update(cx, |p, cx| p.git_scans_complete(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(main_project.clone(), window, cx));
+
+    let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(worktree_project.clone(), window, cx)
+    });
+
+    // Activate the worktree workspace so the user is "looking at" it.
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.activate(worktree_workspace.clone(), window, cx);
+    });
+
+    let sidebar = setup_sidebar(&multi_workspace, cx);
+
+    let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone());
+    let _main_panel = add_agent_panel(&main_workspace, cx);
+    let worktree_panel = add_agent_panel(&worktree_workspace, cx);
+
+    // Open a thread in the worktree workspace's panel.
+    open_thread_with_connection(&worktree_panel, StubAgentConnection::new(), cx);
+    send_message(&worktree_panel, cx);
+    let thread_session_id = active_session_id(&worktree_panel, cx);
+
+    // Save thread metadata associated with the worktree workspace.
+    save_thread_metadata(
+        thread_session_id.clone(),
+        "Worktree Thread".into(),
+        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+        None,
+        &worktree_project,
+        cx,
+    );
+    cx.run_until_parked();
+
+    // Set the sidebar's active entry to this thread.
+    sidebar.update_in(cx, |sidebar, _window, cx| {
+        sidebar.active_entry = Some(ActiveEntry::Thread {
+            session_id: thread_session_id.clone(),
+            workspace: worktree_workspace.clone(),
+        });
+        cx.notify();
+    });
+    cx.run_until_parked();
+
+    // Verify the active workspace is the worktree workspace before archiving.
+    let active_before = multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index());
+    let worktree_index = multi_workspace.read_with(cx, |mw, _| {
+        mw.workspaces()
+            .iter()
+            .position(|ws| ws == &worktree_workspace)
+            .unwrap()
+    });
+    assert_eq!(
+        active_before, worktree_index,
+        "before archiving, the worktree workspace should be active"
+    );
+
+    // Archive the thread.
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        sidebar.archive_thread(&thread_session_id, window, cx);
+    });
+    cx.run_until_parked();
+
+    // After archiving, the active workspace should be the main workspace,
+    // not the doomed worktree workspace.
+    let active_after = multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index());
+    let main_index = multi_workspace.read_with(cx, |mw, _| {
+        mw.workspaces()
+            .iter()
+            .position(|ws| ws == &main_workspace)
+            .unwrap()
+    });
+    assert_eq!(
+        active_after, main_index,
+        "after archiving, the sidebar should have switched to the main (surviving) workspace"
+    );
+
+    // The active entry should be a Draft on the main workspace, not the
+    // worktree workspace.
+    sidebar.read_with(cx, |sidebar, _| match &sidebar.active_entry {
+        Some(ActiveEntry::Draft(ws)) => {
+            assert_eq!(
+                ws, &main_workspace,
+                "active_entry should be Draft on the main workspace, not the worktree"
+            );
+        }
+        other => panic!(
+            "expected ActiveEntry::Draft on main workspace, got {:?}",
+            other
+        ),
+    });
+}