finish the feature

Mikayla Maki created

Change summary

crates/agent_ui/src/agent_panel.rs |   5 
crates/sidebar/src/sidebar.rs      | 212 +++++++++++++++++++++----------
2 files changed, 148 insertions(+), 69 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs 🔗

@@ -2102,6 +2102,11 @@ impl AgentPanel {
         }
     }
 
+    pub fn remove_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
+        self.background_threads.remove(session_id);
+        cx.notify();
+    }
+
     pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
         match &self.active_view {
             ActiveView::AgentThread {

crates/sidebar/src/sidebar.rs 🔗

@@ -1055,6 +1055,8 @@ impl Sidebar {
                     }
                 }
 
+                draft_entries.sort_by_key(|d| d.thread.entity_id());
+
                 // Emit "New Thread" entries for threadless workspaces.
                 // If a threadless workspace has a draft, attach it.
                 let mut used_draft_indices: HashSet<usize> = HashSet::new();
@@ -1347,12 +1349,7 @@ impl Sidebar {
             IconName::ChevronDown
         };
 
-        let has_new_thread_entry = self
-            .contents
-            .entries
-            .get(ix + 1)
-            .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. }));
-        let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
+        let show_new_thread_button = !self.has_filter_query(cx);
 
         let workspace_for_remove = workspace.clone();
         let workspace_for_menu = workspace.clone();
@@ -1517,6 +1514,7 @@ impl Sidebar {
                         )
                     })
                     .when(show_new_thread_button, |this| {
+                        // hidden during search
                         this.child(
                             IconButton::new(
                                 SharedString::from(format!(
@@ -2417,74 +2415,89 @@ impl Sidebar {
     ) {
         ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(session_id, cx));
 
-        // If we're archiving the currently focused thread, move focus to the
-        // nearest thread within the same project group. We never cross group
-        // boundaries — if the group has no other threads, clear focus and open
-        // a blank new thread in the panel instead.
         if self
             .active_entry
             .as_ref()
             .is_some_and(|e| e.is_active_thread(session_id))
         {
-            let current_pos = self.contents.entries.iter().position(|entry| {
-                matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id)
-            });
+            self.navigate_to_nearest_sibling(session_id, window, cx);
+        }
+    }
 
-            // Find the workspace that owns this thread's project group by
-            // walking backwards to the nearest ProjectHeader. We must use
-            // *this* workspace (not the active workspace) because the user
-            // might be archiving a thread in a non-active group.
-            let group_workspace = current_pos.and_then(|pos| {
-                self.contents.entries[..pos]
-                    .iter()
-                    .rev()
-                    .find_map(|e| match e {
-                        ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
-                        _ => None,
-                    })
-            });
+    /// Navigate to the nearest thread sibling within the same project group,
+    /// or fall back to a blank draft if no siblings exist.
+    fn navigate_to_nearest_sibling(
+        &mut self,
+        session_id: &acp::SessionId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let current_pos = self.contents.entries.iter().position(|entry| match entry {
+            ListEntry::Thread(t) => &t.metadata.session_id == session_id,
+            ListEntry::NewThread { draft_thread, .. } => draft_thread
+                .as_ref()
+                .is_some_and(|t| t.read(cx).session_id() == session_id),
+            _ => false,
+        });
 
-            let next_thread = current_pos.and_then(|pos| {
-                let group_start = self.contents.entries[..pos]
-                    .iter()
-                    .rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
-                    .map_or(0, |i| i + 1);
-                let group_end = self.contents.entries[pos + 1..]
-                    .iter()
-                    .position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
-                    .map_or(self.contents.entries.len(), |i| pos + 1 + i);
+        let group_workspace = current_pos.and_then(|pos| {
+            self.contents.entries[..pos]
+                .iter()
+                .rev()
+                .find_map(|e| match e {
+                    ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
+                    _ => None,
+                })
+        });
 
-                let above = self.contents.entries[group_start..pos]
-                    .iter()
-                    .rev()
-                    .find_map(|entry| {
-                        if let ListEntry::Thread(t) = entry {
-                            Some(t)
-                        } else {
-                            None
-                        }
-                    });
+        enum Sibling {
+            Thread(ThreadEntry),
+            Draft {
+                session_id: acp::SessionId,
+                workspace: Entity<Workspace>,
+            },
+        }
 
-                above.or_else(|| {
-                    self.contents.entries[pos + 1..group_end]
-                        .iter()
-                        .find_map(|entry| {
-                            if let ListEntry::Thread(t) = entry {
-                                Some(t)
-                            } else {
-                                None
-                            }
-                        })
-                })
-            });
+        let is_sibling = |entry: &ListEntry| -> Option<Sibling> {
+            match entry {
+                ListEntry::Thread(t) => Some(Sibling::Thread(t.clone())),
+                ListEntry::NewThread {
+                    draft_thread: Some(thread),
+                    workspace,
+                    ..
+                } => Some(Sibling::Draft {
+                    session_id: thread.read(cx).session_id().clone(),
+                    workspace: workspace.clone(),
+                }),
+                _ => None,
+            }
+        };
+
+        let next_sibling = current_pos.and_then(|pos| {
+            let group_start = self.contents.entries[..pos]
+                .iter()
+                .rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
+                .map_or(0, |i| i + 1);
+            let group_end = self.contents.entries[pos + 1..]
+                .iter()
+                .position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
+                .map_or(self.contents.entries.len(), |i| pos + 1 + i);
+
+            let above = self.contents.entries[group_start..pos]
+                .iter()
+                .rev()
+                .find_map(is_sibling);
+
+            above.or_else(|| {
+                self.contents.entries[pos + 1..group_end]
+                    .iter()
+                    .find_map(is_sibling)
+            })
+        });
 
-            if let Some(next) = next_thread {
+        match next_sibling {
+            Some(Sibling::Thread(next)) => {
                 let next_metadata = next.metadata.clone();
-                // Use the thread's own workspace when it has one open (e.g. an absorbed
-                // linked worktree thread that appears under the main workspace's header
-                // 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 {
                     ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
                     ThreadEntryWorkspace::Closed(_) => group_workspace,
@@ -2512,19 +2525,54 @@ impl Sidebar {
                         });
                     }
                 }
-            } else {
+            }
+            Some(Sibling::Draft {
+                session_id: draft_id,
+                workspace,
+            }) => {
+                self.create_new_thread(&workspace, Some(draft_id), window, cx);
+            }
+            None => {
                 if let Some(workspace) = &group_workspace {
                     self.active_entry = Some(ActiveEntry::draft_for_workspace(workspace.clone()));
-                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
-                        agent_panel.update(cx, |panel, cx| {
-                            panel.new_thread(&NewThread, window, cx);
-                        });
-                    }
                 }
             }
         }
     }
 
+    fn dismiss_draft_thread(
+        &mut self,
+        session_id: &acp::SessionId,
+        workspace: &Entity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let is_active = self.active_entry.as_ref().is_some_and(|entry| match entry {
+            ActiveEntry::Draft {
+                session_id: Some(id),
+                workspace: ws,
+            } => id == session_id && ws == workspace,
+            _ => false,
+        });
+
+        if is_active {
+            self.navigate_to_nearest_sibling(session_id, window, cx);
+        }
+
+        // Remove the conversation from the panel so the AcpThread entity
+        // is dropped, which prevents its observer from re-saving metadata.
+        if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+            agent_panel.update(cx, |panel, cx| {
+                panel.remove_thread(session_id, cx);
+            });
+        }
+
+        ThreadMetadataStore::global(cx)
+            .update(cx, |store, cx| store.delete(session_id.clone(), cx));
+
+        self.update_entries(cx);
+    }
+
     fn remove_selected_thread(
         &mut self,
         _: &RemoveSelectedThread,
@@ -3144,6 +3192,8 @@ impl Sidebar {
         let draft_session_id = draft_thread.map(|thread| thread.read(cx).session_id().clone());
         let id = SharedString::from(format!("new-thread-btn-{}", ix));
 
+        let is_hovered = self.hovered_thread_index == Some(ix);
+
         let thread_item = ThreadItem::new(id, label)
             .icon(IconName::Plus)
             .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
@@ -3159,7 +3209,31 @@ impl Sidebar {
             )
             .selected(is_active)
             .focused(is_selected)
+            .hovered(is_hovered)
+            .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
+                if *is_hovered {
+                    this.hovered_thread_index = Some(ix);
+                } else if this.hovered_thread_index == Some(ix) {
+                    this.hovered_thread_index = None;
+                }
+                cx.notify();
+            }))
+            .when(is_hovered && draft_session_id.is_some(), |this| {
+                let session_id = draft_session_id.clone().unwrap();
+                let workspace = workspace.clone();
+                this.action_slot(
+                    IconButton::new("dismiss-draft", IconName::Close)
+                        .icon_size(IconSize::Small)
+                        .icon_color(Color::Muted)
+                        .tooltip(Tooltip::text("Dismiss Draft"))
+                        .on_click(cx.listener(move |this, _, window, cx| {
+                            this.dismiss_draft_thread(&session_id, &workspace, window, cx);
+                        })),
+                )
+            })
             .when(!is_active, |this| {
+                let workspace = workspace.clone();
+                let draft_session_id = draft_session_id.clone();
                 this.on_click(cx.listener(move |this, _, window, cx| {
                     this.selection = None;
                     this.create_new_thread(&workspace, draft_session_id.clone(), window, cx);