Add cancellable worktree creation with status toast and stop button

Richard Feldman created

- Add cancellable job mechanism to the git worker queue with CancellationToken
- Add kill_on_drop(true) to git worktree add subprocess
- Replace inline 'Creating Worktree...' banner with a status toast with Cancel button
- Add stop button in the agent panel toolbar during worktree creation
- Show spinner on sidebar 'New Thread' entry during worktree creation
- Cancel in-progress worktree creation when Cmd-N starts a new thread
- Clean up partially-created worktrees on cancellation

Change summary

Cargo.lock                           |   1 
crates/agent_ui/Cargo.toml           |   1 
crates/agent_ui/src/agent_panel.rs   | 218 ++++++++++++++----
crates/git/src/repository.rs         |   4 
crates/git_ui/src/worktree_picker.rs |   4 
crates/project/src/git_store.rs      | 341 ++++++++++++++++++++++++++++-
crates/sidebar/src/sidebar.rs        |  10 
7 files changed, 509 insertions(+), 70 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -358,6 +358,7 @@ dependencies = [
  "futures 0.3.31",
  "fuzzy",
  "git",
+ "git_ui",
  "gpui",
  "gpui_tokio",
  "heapless",

crates/agent_ui/Cargo.toml πŸ”—

@@ -59,6 +59,7 @@ file_icons.workspace = true
 fs.workspace = true
 futures.workspace = true
 git.workspace = true
+git_ui.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
 gpui_tokio.workspace = true

crates/agent_ui/src/agent_panel.rs πŸ”—

@@ -70,6 +70,7 @@ use gpui::{
 };
 use language::LanguageRegistry;
 use language_model::{ConfigurationError, LanguageModelRegistry};
+use notifications::status_toast::{StatusToast, ToastIcon};
 use project::project_settings::ProjectSettings;
 use project::{Project, ProjectPath, Worktree};
 use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
@@ -751,6 +752,12 @@ pub struct AgentPanel {
     _thread_view_subscription: Option<Subscription>,
     _active_thread_focus_subscription: Option<Subscription>,
     _worktree_creation_task: Option<Task<()>>,
+    worktree_creation_tokens: Vec<(
+        Entity<project::git_store::Repository>,
+        PathBuf,
+        project::git_store::CancellationToken,
+    )>,
+    worktree_creation_toast: Option<Entity<StatusToast>>,
     show_trust_workspace_message: bool,
     last_configuration_error_telemetry: Option<String>,
     on_boarding_upsell_dismissed: AtomicBool,
@@ -1084,6 +1091,8 @@ impl AgentPanel {
             _thread_view_subscription: None,
             _active_thread_focus_subscription: None,
             _worktree_creation_task: None,
+            worktree_creation_tokens: Vec::new(),
+            worktree_creation_toast: None,
             show_trust_workspace_message: false,
             last_configuration_error_telemetry: None,
             on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
@@ -1179,6 +1188,12 @@ impl AgentPanel {
     }
 
     pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
+        if matches!(
+            self.worktree_creation_status,
+            Some(WorktreeCreationStatus::Creating)
+        ) {
+            self.cancel_worktree_creation(window, cx);
+        }
         self.reset_start_thread_in_to_default(cx);
         self.external_thread(None, None, None, None, None, true, window, cx);
     }
@@ -2632,24 +2647,34 @@ impl AgentPanel {
             futures::channel::oneshot::Receiver<Result<()>>,
         )>,
         Vec<(PathBuf, PathBuf)>,
+        Vec<(
+            Entity<project::git_store::Repository>,
+            PathBuf,
+            project::git_store::CancellationToken,
+        )>,
     )> {
         let mut creation_infos = Vec::new();
         let mut path_remapping = Vec::new();
+        let mut tokens = Vec::new();
 
         for repo in git_repos {
-            let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
+            let (work_dir, new_path, receiver, token) = repo.update(cx, |repo, _cx| {
                 let new_path =
                     repo.path_for_new_linked_worktree(branch_name, worktree_directory_setting)?;
-                let receiver =
-                    repo.create_worktree(branch_name.to_string(), new_path.clone(), None);
+                let (receiver, token) = repo.create_cancellable_worktree(
+                    branch_name.to_string(),
+                    new_path.clone(),
+                    None,
+                );
                 let work_dir = repo.work_directory_abs_path.clone();
-                anyhow::Ok((work_dir, new_path, receiver))
+                anyhow::Ok((work_dir, new_path, receiver, token))
             })?;
             path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
+            tokens.push((repo.clone(), new_path.clone(), token));
             creation_infos.push((repo.clone(), new_path, receiver));
         }
 
-        Ok((creation_infos, path_remapping))
+        Ok((creation_infos, path_remapping, tokens))
     }
 
     /// Waits for every in-flight worktree creation to complete. If any
@@ -2730,6 +2755,47 @@ impl AgentPanel {
         Err(anyhow!(error_message))
     }
 
+    fn cancel_worktree_creation(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        if !matches!(
+            self.worktree_creation_status,
+            Some(WorktreeCreationStatus::Creating)
+        ) {
+            return;
+        }
+
+        self._worktree_creation_task = None;
+
+        let tokens = std::mem::take(&mut self.worktree_creation_tokens);
+        for (_repo, path_for_cleanup, token) in tokens {
+            drop(token.cancel(move |state, cx| {
+                cx.background_spawn(async move {
+                    if let project::git_store::RepositoryState::Local(local) = state {
+                        local
+                            .backend
+                            .remove_worktree(path_for_cleanup, true)
+                            .await
+                            .log_err();
+                    }
+                })
+            }));
+        }
+
+        self.worktree_creation_status = None;
+
+        if let Some(toast) = self.worktree_creation_toast.take() {
+            toast.update(cx, |_, cx| cx.emit(DismissEvent));
+        }
+
+        cx.notify();
+    }
+
+    pub fn is_creating_worktree(&self) -> bool {
+        matches!(
+            self.worktree_creation_status,
+            Some(WorktreeCreationStatus::Creating)
+        )
+    }
+
     fn set_worktree_creation_error(
         &mut self,
         message: SharedString,
@@ -2737,6 +2803,10 @@ impl AgentPanel {
         cx: &mut Context<Self>,
     ) {
         self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message));
+        self.worktree_creation_tokens.clear();
+        if let Some(toast) = self.worktree_creation_toast.take() {
+            toast.update(cx, |_, cx| cx.emit(DismissEvent));
+        }
         if matches!(self.active_view, ActiveView::Uninitialized) {
             let selected_agent_type = self.selected_agent_type.clone();
             self.new_agent_thread(selected_agent_type, window, cx);
@@ -2760,6 +2830,22 @@ impl AgentPanel {
         self.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
         cx.notify();
 
+        if let Some(workspace) = self.workspace.upgrade() {
+            let toast = StatusToast::new("Creating worktree\u{2026}", cx, |this, _cx| {
+                this.icon(ToastIcon::new(ui::IconName::LoadCircle).color(ui::Color::Muted))
+                    .action("Cancel", |window, cx| {
+                        window.dispatch_action(
+                            git_ui::worktree_picker::CancelWorktreeCreation.boxed_clone(),
+                            cx,
+                        );
+                    })
+            });
+            self.worktree_creation_toast = Some(toast.clone());
+            workspace.update(cx, |workspace, cx| {
+                workspace.toggle_status_toast(toast, cx);
+            });
+        }
+
         let (git_repos, non_git_paths) = self.classify_worktrees(cx);
 
         if git_repos.is_empty() {
@@ -2835,27 +2921,33 @@ impl AgentPanel {
                     }
                 };
 
-            let (creation_infos, path_remapping) = match this.update_in(cx, |_this, _window, cx| {
-                Self::start_worktree_creations(
-                    &git_repos,
-                    &branch_name,
-                    &worktree_directory_setting,
-                    cx,
-                )
-            }) {
-                Ok(Ok(result)) => result,
-                Ok(Err(err)) | Err(err) => {
-                    this.update_in(cx, |this, window, cx| {
-                        this.set_worktree_creation_error(
-                            format!("Failed to validate worktree directory: {err}").into(),
-                            window,
-                            cx,
-                        );
-                    })
-                    .log_err();
-                    return anyhow::Ok(());
-                }
-            };
+            let (creation_infos, path_remapping, tokens) =
+                match this.update_in(cx, |_this, _window, cx| {
+                    Self::start_worktree_creations(
+                        &git_repos,
+                        &branch_name,
+                        &worktree_directory_setting,
+                        cx,
+                    )
+                }) {
+                    Ok(Ok(result)) => result,
+                    Ok(Err(err)) | Err(err) => {
+                        this.update_in(cx, |this, window, cx| {
+                            this.set_worktree_creation_error(
+                                format!("Failed to validate worktree directory: {err}").into(),
+                                window,
+                                cx,
+                            );
+                        })
+                        .log_err();
+                        return anyhow::Ok(());
+                    }
+                };
+
+            this.update_in(cx, |this, _window, _cx| {
+                this.worktree_creation_tokens = tokens;
+            })
+            .ok();
 
             let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await
             {
@@ -2886,6 +2978,14 @@ impl AgentPanel {
                 }
             };
 
+            this.update_in(cx, |this, _window, cx| {
+                this.worktree_creation_tokens.clear();
+                if let Some(toast) = this.worktree_creation_toast.take() {
+                    toast.update(cx, |_, cx| cx.emit(DismissEvent));
+                }
+            })
+            .ok();
+
             let this_for_error = this.clone();
             if let Err(err) = Self::setup_new_workspace(
                 this,
@@ -4112,6 +4212,24 @@ impl AgentPanel {
                                     }),
                             )
                         })
+                        .when(
+                            matches!(
+                                self.worktree_creation_status,
+                                Some(WorktreeCreationStatus::Creating)
+                            ),
+                            |this| {
+                                this.child(
+                                    IconButton::new("cancel-worktree-creation", IconName::Stop)
+                                        .icon_size(IconSize::Small)
+                                        .icon_color(Color::Error)
+                                        .style(ButtonStyle::Tinted(ui::TintColor::Error))
+                                        .tooltip(Tooltip::text("Cancel worktree creation"))
+                                        .on_click(cx.listener(|this, _, window, cx| {
+                                            this.cancel_worktree_creation(window, cx);
+                                        })),
+                                )
+                            },
+                        )
                         .child(self.render_panel_options_menu(window, cx)),
                 )
                 .into_any_element()
@@ -4178,37 +4296,34 @@ impl AgentPanel {
                                     }),
                             )
                         })
+                        .when(
+                            matches!(
+                                self.worktree_creation_status,
+                                Some(WorktreeCreationStatus::Creating)
+                            ),
+                            |this| {
+                                this.child(
+                                    IconButton::new("cancel-worktree-creation", IconName::Stop)
+                                        .icon_size(IconSize::Small)
+                                        .icon_color(Color::Error)
+                                        .style(ButtonStyle::Tinted(ui::TintColor::Error))
+                                        .tooltip(Tooltip::text("Cancel worktree creation"))
+                                        .on_click(cx.listener(|this, _, window, cx| {
+                                            this.cancel_worktree_creation(window, cx);
+                                        })),
+                                )
+                            },
+                        )
                         .child(self.render_panel_options_menu(window, cx)),
                 )
                 .into_any_element()
         }
     }
 
-    fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+    fn render_worktree_creation_status(&self, _cx: &mut Context<Self>) -> Option<AnyElement> {
         let status = self.worktree_creation_status.as_ref()?;
         match status {
-            WorktreeCreationStatus::Creating => Some(
-                h_flex()
-                    .absolute()
-                    .bottom_12()
-                    .w_full()
-                    .p_2()
-                    .gap_1()
-                    .justify_center()
-                    .bg(cx.theme().colors().editor_background)
-                    .child(
-                        Icon::new(IconName::LoadCircle)
-                            .size(IconSize::Small)
-                            .color(Color::Muted)
-                            .with_rotate_animation(3),
-                    )
-                    .child(
-                        Label::new("Creating Worktree…")
-                            .color(Color::Muted)
-                            .size(LabelSize::Small),
-                    )
-                    .into_any_element(),
-            ),
+            WorktreeCreationStatus::Creating => None,
             WorktreeCreationStatus::Error(message) => Some(
                 Callout::new()
                     .icon(IconName::Warning)
@@ -4648,6 +4763,11 @@ impl Render for AgentPanel {
             .on_action(cx.listener(Self::decrease_font_size))
             .on_action(cx.listener(Self::reset_font_size))
             .on_action(cx.listener(Self::toggle_zoom))
+            .on_action(cx.listener(
+                |this, _: &git_ui::worktree_picker::CancelWorktreeCreation, window, cx| {
+                    this.cancel_worktree_creation(window, cx);
+                },
+            ))
             .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
                 if let Some(conversation_view) = this.active_conversation_view() {
                     conversation_view.update(cx, |conversation_view, cx| {

crates/git/src/repository.rs πŸ”—

@@ -1660,7 +1660,9 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 std::fs::create_dir_all(path.parent().unwrap_or(&path))?;
                 let git = git_binary?;
-                let output = git.build_command(&args).output().await?;
+                let mut command = git.build_command(&args);
+                command.kill_on_drop(true);
+                let output = command.output().await?;
                 if output.status.success() {
                     Ok(())
                 } else {

crates/git_ui/src/worktree_picker.rs πŸ”—

@@ -29,7 +29,9 @@ actions!(
     [
         WorktreeFromDefault,
         WorktreeFromDefaultOnWindow,
-        DeleteWorktree
+        DeleteWorktree,
+        /// Cancels an in-progress worktree creation.
+        CancelWorktreeCreation,
     ]
 );
 

crates/project/src/git_store.rs πŸ”—

@@ -298,6 +298,47 @@ pub struct RepositorySnapshot {
 
 type JobId = u64;
 
+#[derive(Copy, Clone)]
+pub enum CancelOutcome {
+    /// The job was still queued and was removed before it started.
+    RemovedFromQueue,
+    /// The job was running; the process was killed. Cleanup callback has been awaited.
+    KilledRunning,
+    /// The job had already finished before the cancel arrived.
+    AlreadyFinished,
+}
+
+pub struct CancellationToken {
+    id: JobId,
+    job_sender: mpsc::UnboundedSender<GitWorkerMessage>,
+}
+
+impl CancellationToken {
+    pub fn cancel(
+        self,
+        cleanup: impl FnOnce(RepositoryState, &mut AsyncApp) -> Task<()> + 'static,
+    ) -> oneshot::Receiver<CancelOutcome> {
+        let (result_tx, result_rx) = oneshot::channel();
+        self.job_sender
+            .unbounded_send(GitWorkerMessage::Cancel {
+                id: self.id,
+                cleanup: Some(Box::new(cleanup)),
+                result_tx,
+            })
+            .ok();
+        result_rx
+    }
+}
+
+enum GitWorkerMessage {
+    Job(GitJob),
+    Cancel {
+        id: JobId,
+        cleanup: Option<Box<dyn FnOnce(RepositoryState, &mut AsyncApp) -> Task<()>>>,
+        result_tx: oneshot::Sender<CancelOutcome>,
+    },
+}
+
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct JobInfo {
     pub start: Instant,
@@ -336,7 +377,7 @@ pub struct Repository {
     // For a local repository, holds paths that have had worktree events since the last status scan completed,
     // and that should be examined during the next status scan.
     paths_needing_status_update: Vec<Vec<RepoPath>>,
-    job_sender: mpsc::UnboundedSender<GitJob>,
+    job_sender: mpsc::UnboundedSender<GitWorkerMessage>,
     active_jobs: HashMap<JobId, JobInfo>,
     pending_ops: SumTree<PendingOps>,
     job_id: JobId,
@@ -455,6 +496,7 @@ impl EventEmitter<JobsUpdated> for Repository {}
 impl EventEmitter<GitStoreEvent> for GitStore {}
 
 pub struct GitJob {
+    id: JobId,
     job: Box<dyn FnOnce(RepositoryState, &mut AsyncApp) -> Task<()>>,
     key: Option<GitJobKey>,
 }
@@ -4175,6 +4217,57 @@ impl Repository {
         self.send_keyed_job(None, status, job)
     }
 
+    pub fn send_cancellable_job<F, Fut, R>(
+        &mut self,
+        status: Option<SharedString>,
+        job: F,
+    ) -> (oneshot::Receiver<R>, CancellationToken)
+    where
+        F: FnOnce(RepositoryState, AsyncApp) -> Fut + 'static,
+        Fut: Future<Output = R> + 'static,
+        R: Send + 'static,
+    {
+        let (result_tx, result_rx) = futures::channel::oneshot::channel();
+        let job_id = post_inc(&mut self.job_id);
+        let this = self.this.clone();
+        let token = CancellationToken {
+            id: job_id,
+            job_sender: self.job_sender.clone(),
+        };
+        self.job_sender
+            .unbounded_send(GitWorkerMessage::Job(GitJob {
+                id: job_id,
+                key: None,
+                job: Box::new(move |state, cx: &mut AsyncApp| {
+                    let job = job(state, cx.clone());
+                    cx.spawn(async move |cx| {
+                        if let Some(s) = status.clone() {
+                            this.update(cx, |this, cx| {
+                                this.active_jobs.insert(
+                                    job_id,
+                                    JobInfo {
+                                        start: Instant::now(),
+                                        message: s.clone(),
+                                    },
+                                );
+                                cx.notify();
+                            })
+                            .ok();
+                        }
+                        let result = job.await;
+                        this.update(cx, |this, cx| {
+                            this.active_jobs.remove(&job_id);
+                            cx.notify();
+                        })
+                        .ok();
+                        result_tx.send(result).ok();
+                    })
+                }),
+            }))
+            .ok();
+        (result_rx, token)
+    }
+
     fn send_keyed_job<F, Fut, R>(
         &mut self,
         key: Option<GitJobKey>,
@@ -4190,7 +4283,8 @@ impl Repository {
         let job_id = post_inc(&mut self.job_id);
         let this = self.this.clone();
         self.job_sender
-            .unbounded_send(GitJob {
+            .unbounded_send(GitWorkerMessage::Job(GitJob {
+                id: job_id,
                 key,
                 job: Box::new(move |state, cx: &mut AsyncApp| {
                     let job = job(state, cx.clone());
@@ -4220,7 +4314,7 @@ impl Repository {
                         result_tx.send(result).ok();
                     })
                 }),
-            })
+            }))
             .ok();
         result_rx
     }
@@ -5882,6 +5976,37 @@ impl Repository {
         )
     }
 
+    pub fn create_cancellable_worktree(
+        &mut self,
+        branch_name: String,
+        path: PathBuf,
+        commit: Option<String>,
+    ) -> (oneshot::Receiver<Result<()>>, CancellationToken) {
+        let id = self.id;
+        self.send_cancellable_job(
+            Some(format!("git worktree add: {}", branch_name).into()),
+            move |repo, _cx| async move {
+                match repo {
+                    RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+                        backend.create_worktree(branch_name, path, commit).await
+                    }
+                    RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+                        client
+                            .request(proto::GitCreateWorktree {
+                                project_id: project_id.0,
+                                repository_id: id.to_proto(),
+                                name: branch_name,
+                                directory: path.to_string_lossy().to_string(),
+                                commit,
+                            })
+                            .await?;
+                        Ok(())
+                    }
+                }
+            },
+        )
+    }
+
     pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver<Result<()>> {
         let id = self.id;
         self.send_job(
@@ -6405,8 +6530,8 @@ impl Repository {
     fn spawn_local_git_worker(
         state: Shared<Task<Result<LocalRepositoryState, String>>>,
         cx: &mut Context<Self>,
-    ) -> mpsc::UnboundedSender<GitJob> {
-        let (job_tx, mut job_rx) = mpsc::unbounded::<GitJob>();
+    ) -> mpsc::UnboundedSender<GitWorkerMessage> {
+        let (job_tx, mut job_rx) = mpsc::unbounded::<GitWorkerMessage>();
 
         cx.spawn(async move |_, cx| {
             let state = state.await.map_err(|err| anyhow::anyhow!(err))?;
@@ -6420,10 +6545,34 @@ impl Repository {
                 .await;
             }
             let state = RepositoryState::Local(state);
-            let mut jobs = VecDeque::new();
+            let mut jobs: VecDeque<GitJob> = VecDeque::new();
+            let mut completed_ids: HashSet<JobId> = HashSet::new();
             loop {
-                while let Ok(Some(next_job)) = job_rx.try_next() {
-                    jobs.push_back(next_job);
+                while let Ok(Some(msg)) = job_rx.try_next() {
+                    match msg {
+                        GitWorkerMessage::Job(job) => jobs.push_back(job),
+                        GitWorkerMessage::Cancel {
+                            id,
+                            cleanup,
+                            result_tx,
+                        } => {
+                            let before = jobs.len();
+                            jobs.retain(|j| j.id != id);
+                            let outcome = if jobs.len() < before {
+                                CancelOutcome::RemovedFromQueue
+                            } else if completed_ids.contains(&id) {
+                                CancelOutcome::AlreadyFinished
+                            } else {
+                                CancelOutcome::AlreadyFinished
+                            };
+                            result_tx.send(outcome).ok();
+                            if let Some(f) = cleanup {
+                                if matches!(outcome, CancelOutcome::RemovedFromQueue) {
+                                    f(state.clone(), cx).await;
+                                }
+                            }
+                        }
+                    }
                 }
 
                 if let Some(job) = jobs.pop_front() {
@@ -6434,9 +6583,74 @@ impl Repository {
                     {
                         continue;
                     }
-                    (job.job)(state.clone(), cx).await;
-                } else if let Some(job) = job_rx.next().await {
-                    jobs.push_back(job);
+                    let running_job_id = job.id;
+                    let task = (job.job)(state.clone(), cx);
+                    let mut task = std::pin::pin!(task);
+
+                    loop {
+                        match futures::future::select(task, job_rx.next()).await {
+                            futures::future::Either::Left(((), _)) => {
+                                completed_ids.insert(running_job_id);
+                                if completed_ids.len() > 256 {
+                                    let to_remove: Vec<_> =
+                                        completed_ids.iter().copied().take(128).collect();
+                                    for id in to_remove {
+                                        completed_ids.remove(&id);
+                                    }
+                                }
+                                break;
+                            }
+                            futures::future::Either::Right((Some(msg), ongoing_task)) => {
+                                match msg {
+                                    GitWorkerMessage::Job(j) => {
+                                        jobs.push_back(j);
+                                        task = ongoing_task;
+                                    }
+                                    GitWorkerMessage::Cancel {
+                                        id,
+                                        cleanup,
+                                        result_tx,
+                                    } => {
+                                        if id == running_job_id {
+                                            let _ = ongoing_task;
+                                            if let Some(f) = cleanup {
+                                                f(state.clone(), cx).await;
+                                            }
+                                            result_tx.send(CancelOutcome::KilledRunning).ok();
+                                            break;
+                                        } else {
+                                            let before = jobs.len();
+                                            jobs.retain(|j| j.id != id);
+                                            let outcome = if jobs.len() < before {
+                                                CancelOutcome::RemovedFromQueue
+                                            } else if completed_ids.contains(&id) {
+                                                CancelOutcome::AlreadyFinished
+                                            } else {
+                                                CancelOutcome::AlreadyFinished
+                                            };
+                                            result_tx.send(outcome).ok();
+                                            task = ongoing_task;
+                                        }
+                                    }
+                                }
+                            }
+                            futures::future::Either::Right((None, ongoing_task)) => {
+                                ongoing_task.await;
+                                break;
+                            }
+                        }
+                    }
+                } else if let Some(msg) = job_rx.next().await {
+                    match msg {
+                        GitWorkerMessage::Job(job) => jobs.push_back(job),
+                        GitWorkerMessage::Cancel {
+                            id: _,
+                            cleanup: _,
+                            result_tx,
+                        } => {
+                            result_tx.send(CancelOutcome::AlreadyFinished).ok();
+                        }
+                    }
                 } else {
                     break;
                 }
@@ -6451,15 +6665,39 @@ impl Repository {
     fn spawn_remote_git_worker(
         state: RemoteRepositoryState,
         cx: &mut Context<Self>,
-    ) -> mpsc::UnboundedSender<GitJob> {
-        let (job_tx, mut job_rx) = mpsc::unbounded::<GitJob>();
+    ) -> mpsc::UnboundedSender<GitWorkerMessage> {
+        let (job_tx, mut job_rx) = mpsc::unbounded::<GitWorkerMessage>();
 
         cx.spawn(async move |_, cx| {
             let state = RepositoryState::Remote(state);
-            let mut jobs = VecDeque::new();
+            let mut jobs: VecDeque<GitJob> = VecDeque::new();
+            let mut completed_ids: HashSet<JobId> = HashSet::new();
             loop {
-                while let Ok(Some(next_job)) = job_rx.try_next() {
-                    jobs.push_back(next_job);
+                while let Ok(Some(msg)) = job_rx.try_next() {
+                    match msg {
+                        GitWorkerMessage::Job(job) => jobs.push_back(job),
+                        GitWorkerMessage::Cancel {
+                            id,
+                            cleanup,
+                            result_tx,
+                        } => {
+                            let before = jobs.len();
+                            jobs.retain(|j| j.id != id);
+                            let outcome = if jobs.len() < before {
+                                CancelOutcome::RemovedFromQueue
+                            } else if completed_ids.contains(&id) {
+                                CancelOutcome::AlreadyFinished
+                            } else {
+                                CancelOutcome::AlreadyFinished
+                            };
+                            result_tx.send(outcome).ok();
+                            if let Some(f) = cleanup {
+                                if matches!(outcome, CancelOutcome::RemovedFromQueue) {
+                                    f(state.clone(), cx).await;
+                                }
+                            }
+                        }
+                    }
                 }
 
                 if let Some(job) = jobs.pop_front() {
@@ -6470,9 +6708,74 @@ impl Repository {
                     {
                         continue;
                     }
-                    (job.job)(state.clone(), cx).await;
-                } else if let Some(job) = job_rx.next().await {
-                    jobs.push_back(job);
+                    let running_job_id = job.id;
+                    let task = (job.job)(state.clone(), cx);
+                    let mut task = std::pin::pin!(task);
+
+                    loop {
+                        match futures::future::select(task, job_rx.next()).await {
+                            futures::future::Either::Left(((), _)) => {
+                                completed_ids.insert(running_job_id);
+                                if completed_ids.len() > 256 {
+                                    let to_remove: Vec<_> =
+                                        completed_ids.iter().copied().take(128).collect();
+                                    for id in to_remove {
+                                        completed_ids.remove(&id);
+                                    }
+                                }
+                                break;
+                            }
+                            futures::future::Either::Right((Some(msg), ongoing_task)) => {
+                                match msg {
+                                    GitWorkerMessage::Job(j) => {
+                                        jobs.push_back(j);
+                                        task = ongoing_task;
+                                    }
+                                    GitWorkerMessage::Cancel {
+                                        id,
+                                        cleanup,
+                                        result_tx,
+                                    } => {
+                                        if id == running_job_id {
+                                            let _ = ongoing_task;
+                                            if let Some(f) = cleanup {
+                                                f(state.clone(), cx).await;
+                                            }
+                                            result_tx.send(CancelOutcome::KilledRunning).ok();
+                                            break;
+                                        } else {
+                                            let before = jobs.len();
+                                            jobs.retain(|j| j.id != id);
+                                            let outcome = if jobs.len() < before {
+                                                CancelOutcome::RemovedFromQueue
+                                            } else if completed_ids.contains(&id) {
+                                                CancelOutcome::AlreadyFinished
+                                            } else {
+                                                CancelOutcome::AlreadyFinished
+                                            };
+                                            result_tx.send(outcome).ok();
+                                            task = ongoing_task;
+                                        }
+                                    }
+                                }
+                            }
+                            futures::future::Either::Right((None, ongoing_task)) => {
+                                ongoing_task.await;
+                                break;
+                            }
+                        }
+                    }
+                } else if let Some(msg) = job_rx.next().await {
+                    match msg {
+                        GitWorkerMessage::Job(job) => jobs.push_back(job),
+                        GitWorkerMessage::Cancel {
+                            id: _,
+                            cleanup: _,
+                            result_tx,
+                        } => {
+                            result_tx.send(CancelOutcome::AlreadyFinished).ok();
+                        }
+                    }
                 } else {
                     break;
                 }

crates/sidebar/src/sidebar.rs πŸ”—

@@ -300,6 +300,7 @@ pub struct Sidebar {
     focused_thread: Option<acp::SessionId>,
     agent_panel_visible: bool,
     active_thread_is_draft: bool,
+    is_creating_worktree: bool,
     hovered_thread_index: Option<usize>,
     collapsed_groups: HashSet<PathList>,
     expanded_groups: HashMap<PathList, usize>,
@@ -399,6 +400,7 @@ impl Sidebar {
             focused_thread: None,
             agent_panel_visible: false,
             active_thread_is_draft: false,
+            is_creating_worktree: false,
             hovered_thread_index: None,
             collapsed_groups: HashSet::new(),
             expanded_groups: HashMap::new(),
@@ -652,6 +654,11 @@ impl Sidebar {
             .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
             .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx));
 
+        self.is_creating_worktree = active_workspace
+            .as_ref()
+            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+            .map_or(false, |panel| panel.read(cx).is_creating_worktree());
+
         // Derive focused_thread from the active workspace's agent panel.
         // Only update when the panel gives us a positive signal β€” if the
         // panel returns None (e.g. still loading after a thread activation),
@@ -2934,6 +2941,9 @@ impl Sidebar {
             .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
             .selected(is_active)
             .focused(is_selected)
+            .when(self.is_creating_worktree && is_active, |this| {
+                this.status(AgentThreadStatus::Running)
+            })
             .when(!is_active, |this| {
                 this.on_click(cx.listener(move |this, _, window, cx| {
                     this.selection = None;