Add cancel button and spinner for pending worktree restores

Richard Feldman created

Track in-flight worktree restores in the Sidebar via a
HashSet<SessionId>. While a restore is pending, the thread item shows
a spinning 'Restoring worktree…' indicator with a cancel button that
appears on hover. Clicking cancel clears the pending state.

This is purely UI — the restore itself still runs to completion in the
background; cancel just hides the spinner.

Change summary

crates/agent_ui/src/thread_worktree_archive.rs |  4 
crates/sidebar/src/sidebar.rs                  | 54 ++++++++++++-----
crates/ui/src/components/ai/thread_item.rs     | 59 +++++++++++++++++++
3 files changed, 96 insertions(+), 21 deletions(-)

Detailed changes

crates/agent_ui/src/thread_worktree_archive.rs 🔗

@@ -968,10 +968,10 @@ pub async fn restore_worktree_via_git(
 
     let commit_exists = main_repo
         .update(cx, |repo, _cx| {
-            repo.resolve_commit(row.original_commit_hash.clone())
+            repo.commit_exists(row.original_commit_hash.clone())
         })
         .await
-        .map_err(|_| anyhow!("resolve_commit was canceled"))?
+        .map_err(|_| anyhow!("commit_exists check was canceled"))?
         .context("failed to check if original commit exists")?;
 
     if !commit_exists {

crates/sidebar/src/sidebar.rs 🔗

@@ -17,8 +17,8 @@ use chrono::{DateTime, Utc};
 use editor::Editor;
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
 use gpui::{
-    Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState,
-    Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, linear_color_stop,
+    Action as _, AnyElement, App, ClickEvent, Context, Entity, FocusHandle, Focusable, KeyContext,
+    ListState, Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, linear_color_stop,
     linear_gradient, list, prelude::*, px,
 };
 use menu::{
@@ -373,6 +373,7 @@ pub struct Sidebar {
     recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
     project_header_menu_ix: Option<usize>,
     _subscriptions: Vec<gpui::Subscription>,
+    pending_worktree_restores: HashSet<acp::SessionId>,
     _draft_observation: Option<gpui::Subscription>,
 }
 
@@ -464,6 +465,7 @@ impl Sidebar {
             recent_projects_popover_handle: PopoverMenuHandle::default(),
             project_header_menu_ix: None,
             _subscriptions: Vec::new(),
+            pending_worktree_restores: HashSet::default(),
             _draft_observation: None,
         }
     }
@@ -2211,9 +2213,6 @@ impl Sidebar {
         cx.spawn_in(window, async move |this, cx| {
             let archived_worktrees = task.await?;
 
-            // No archived worktrees means the thread wasn't associated with a
-            // linked worktree that got deleted, so we just need to find (or
-            // open) a workspace that matches the thread's folder paths.
             if archived_worktrees.is_empty() {
                 this.update_in(cx, |this, window, cx| {
                     if let Some(workspace) =
@@ -2236,23 +2235,26 @@ impl Sidebar {
                 return anyhow::Ok(());
             }
 
-            // Restore each archived worktree back to disk via git. If the
-            // worktree already exists (e.g. a previous unarchive of a different
-            // thread on the same worktree already restored it), it's reused
-            // as-is. We track (old_path, restored_path) pairs so we can update
-            // the thread's folder_paths afterward.
+            this.update_in(cx, |this, _window, cx| {
+                this.pending_worktree_restores.insert(session_id.clone());
+                cx.notify();
+            })?;
+
             let mut path_replacements: Vec<(PathBuf, PathBuf)> = Vec::new();
             for row in &archived_worktrees {
                 match thread_worktree_archive::restore_worktree_via_git(row, &mut *cx).await {
                     Ok(restored_path) => {
-                        // The worktree is on disk now; clean up the DB record
-                        // and git ref we created during archival.
                         thread_worktree_archive::cleanup_archived_worktree_record(row, &mut *cx)
                             .await;
                         path_replacements.push((row.worktree_path.clone(), restored_path));
                     }
                     Err(error) => {
                         log::error!("Failed to restore worktree: {error:#}");
+                        this.update_in(cx, |this, _window, cx| {
+                            this.pending_worktree_restores.remove(&session_id);
+                            cx.notify();
+                        })
+                        .ok();
                         this.update_in(cx, |this, _window, cx| {
                             if let Some(multi_workspace) = this.multi_workspace.upgrade() {
                                 let workspace = multi_workspace.read(cx).workspace().clone();
@@ -2275,18 +2277,19 @@ impl Sidebar {
                 }
             }
 
+            this.update_in(cx, |this, _window, cx| {
+                this.pending_worktree_restores.remove(&session_id);
+                cx.notify();
+            })
+            .ok();
+
             if !path_replacements.is_empty() {
-                // Update the thread's stored folder_paths: swap each old
-                // worktree path for the restored path (which may differ if
-                // the worktree was restored to a new location).
                 cx.update(|_window, cx| {
                     store.update(cx, |store, cx| {
                         store.complete_worktree_restore(&session_id, &path_replacements, cx);
                     });
                 })?;
 
-                // Re-read the metadata (now with updated paths) and open
-                // the workspace so the user lands in the restored worktree.
                 let updated_metadata =
                     cx.update(|_window, cx| store.read(cx).entry(&session_id).cloned())?;
 
@@ -2923,6 +2926,23 @@ impl Sidebar {
                     })
                     .collect(),
             )
+            .pending_worktree_restore(
+                self.pending_worktree_restores
+                    .contains(&thread.metadata.session_id),
+            )
+            .when(
+                self.pending_worktree_restores
+                    .contains(&thread.metadata.session_id),
+                |this| {
+                    let session_id = thread.metadata.session_id.clone();
+                    this.on_cancel_restore(cx.listener(
+                        move |this, _event: &ClickEvent, _window, cx| {
+                            this.pending_worktree_restores.remove(&session_id);
+                            cx.notify();
+                        },
+                    ))
+                },
+            )
             .timestamp(timestamp)
             .highlight_positions(thread.highlight_positions.to_vec())
             .title_generating(thread.is_title_generating)

crates/ui/src/components/ai/thread_item.rs 🔗

@@ -46,6 +46,8 @@ pub struct ThreadItem {
     project_paths: Option<Arc<[PathBuf]>>,
     project_name: Option<SharedString>,
     worktrees: Vec<ThreadItemWorktreeInfo>,
+    pending_worktree_restore: bool,
+    on_cancel_restore: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
     on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
     action_slot: Option<AnyElement>,
@@ -78,6 +80,8 @@ impl ThreadItem {
             project_paths: None,
             project_name: None,
             worktrees: Vec::new(),
+            pending_worktree_restore: false,
+            on_cancel_restore: None,
             on_click: None,
             on_hover: Box::new(|_, _, _| {}),
             action_slot: None,
@@ -171,6 +175,19 @@ impl ThreadItem {
         self
     }
 
+    pub fn pending_worktree_restore(mut self, pending: bool) -> Self {
+        self.pending_worktree_restore = pending;
+        self
+    }
+
+    pub fn on_cancel_restore(
+        mut self,
+        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_cancel_restore = Some(Box::new(handler));
+        self
+    }
+
     pub fn hovered(mut self, hovered: bool) -> Self {
         self.hovered = hovered;
         self
@@ -211,7 +228,7 @@ impl ThreadItem {
 }
 
 impl RenderOnce for ThreadItem {
-    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+    fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
         let color = cx.theme().colors();
         let sidebar_base_bg = color
             .title_bar_background
@@ -359,7 +376,7 @@ impl RenderOnce for ThreadItem {
 
         let has_project_name = self.project_name.is_some();
         let has_project_paths = project_paths.is_some();
-        let has_worktree = !self.worktrees.is_empty();
+        let has_worktree = !self.worktrees.is_empty() || self.pending_worktree_restore;
         let has_timestamp = !self.timestamp.is_empty();
         let timestamp = self.timestamp;
 
@@ -488,6 +505,44 @@ impl RenderOnce for ThreadItem {
                         );
                     }
 
+                    if self.pending_worktree_restore {
+                        let on_cancel = self.on_cancel_restore.take();
+                        let restore_element = h_flex()
+                            .id(format!("{}-worktree-restore", self.id.clone()))
+                            .gap_1()
+                            .child(
+                                Icon::new(IconName::LoadCircle)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Muted)
+                                    .with_rotate_animation(2),
+                            )
+                            .child(
+                                Label::new("Restoring worktree\u{2026}")
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .when_some(on_cancel, |this, on_cancel| {
+                                this.child(
+                                    IconButton::new(
+                                        format!("{}-cancel-restore", self.id.clone()),
+                                        IconName::Close,
+                                    )
+                                    .icon_size(IconSize::XSmall)
+                                    .icon_color(Color::Muted)
+                                    .tooltip(Tooltip::text("Cancel Restore"))
+                                    .on_click(
+                                        move |event, window, cx| {
+                                            cx.stop_propagation();
+                                            on_cancel(event, window, cx);
+                                        },
+                                    ),
+                                )
+                            })
+                            .tooltip(Tooltip::text("Restoring the Git worktree for this thread"))
+                            .into_any_element();
+                        worktree_labels.push(restore_element);
+                    }
+
                     this.child(
                         h_flex()
                             .min_w_0()