diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 27264571b15c842e7742a3a1770e4f7ae50b4444..0fff69da28cb14c9031a822cb29f5e25e7ee7d95 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/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.background_threads.remove(session_id); + cx.notify(); + } + pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option> { match &self.active_view { ActiveView::AgentThread { diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index c3a2f4bf7e00a0743184d9e749d759abde5e3d09..0b0d4f3a569f9dc1b3df750c91b650f285557b07 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/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 = 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, + ) { + 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, + }, + } - 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 { + 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::(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, + window: &mut Window, + cx: &mut Context, + ) { + 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::(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);