From 224be87fc3086961fd1e91bba77b2d475b2ba48d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:00:40 -0300 Subject: [PATCH] sidebar: Improve the archive view (#51863) - Add the ability to archive a thread from the sidebar - Add the ability to unarchive a thread from the archive view - Improve keyboard navigation within the archive view - Display project names associated with a thread in the archive view Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: cameron --- crates/agent_ui/src/agent_ui.rs | 7 + crates/agent_ui/src/thread_metadata_store.rs | 105 +++++--- crates/agent_ui/src/threads_archive_view.rs | 248 ++++++++++++------- crates/sidebar/src/sidebar.rs | 36 +-- 4 files changed, 253 insertions(+), 143 deletions(-) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index b2fb8d67f7c9d224fe7f58a94396bc1f4c182f7e..fed5a7b5618534c10a1a017302d77a0150e92d2a 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -228,6 +228,13 @@ pub enum Agent { } impl Agent { + pub fn id(&self) -> AgentId { + match self { + Self::NativeAgent => agent::ZED_AGENT_ID.clone(), + Self::Custom { id } => id.clone(), + } + } + pub fn server( &self, fs: Arc, diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index d25a4e147e737e32012a17b1c419cf348307b4b2..97211569750d12108b489aa2d63cba136f616177 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -1,5 +1,6 @@ use std::{path::Path, sync::Arc}; +use acp_thread::AgentSessionInfo; use agent::{ThreadStore, ZED_AGENT_ID}; use agent_client_protocol as acp; use anyhow::Result; @@ -83,6 +84,62 @@ pub struct ThreadMetadata { pub folder_paths: PathList, } +impl ThreadMetadata { + pub fn from_session_info(agent_id: AgentId, session: &AgentSessionInfo) -> Self { + let session_id = session.session_id.clone(); + let title = session.title.clone().unwrap_or_default(); + let updated_at = session.updated_at.unwrap_or_else(|| Utc::now()); + let created_at = session.created_at.unwrap_or(updated_at); + let folder_paths = session.work_dirs.clone().unwrap_or_default(); + let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() { + None + } else { + Some(agent_id) + }; + Self { + session_id, + agent_id, + title, + updated_at, + created_at: Some(created_at), + folder_paths, + } + } + + pub fn from_thread(thread: &Entity, cx: &App) -> Self { + let thread_ref = thread.read(cx); + let session_id = thread_ref.session_id().clone(); + let title = thread_ref.title(); + let updated_at = Utc::now(); + + let agent_id = thread_ref.connection().agent_id(); + + let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() { + None + } else { + Some(agent_id) + }; + + let folder_paths = { + let project = thread_ref.project().read(cx); + let paths: Vec> = project + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path()) + .collect(); + PathList::new(&paths) + }; + + Self { + session_id, + agent_id, + title, + created_at: Some(updated_at), // handled by db `ON CONFLICT` + updated_at, + folder_paths, + } + } +} + pub struct ThreadMetadataStore { db: ThreadMetadataDb, session_subscriptions: HashMap, @@ -119,6 +176,14 @@ impl ThreadMetadataStore { cx.global::().0.clone() } + pub fn list_ids(&self, cx: &App) -> Task>> { + let db = self.db.clone(); + cx.background_spawn(async move { + let s = db.list_ids()?; + Ok(s) + }) + } + pub fn list(&self, cx: &App) -> Task>> { let db = self.db.clone(); cx.background_spawn(async move { @@ -209,44 +274,12 @@ impl ThreadMetadataStore { acp_thread::AcpThreadEvent::NewEntry | acp_thread::AcpThreadEvent::EntryUpdated(_) | acp_thread::AcpThreadEvent::TitleUpdated => { - let metadata = Self::metadata_for_acp_thread(thread.read(cx), cx); + let metadata = ThreadMetadata::from_thread(&thread, cx); self.save(metadata, cx).detach_and_log_err(cx); } _ => {} } } - - fn metadata_for_acp_thread(thread: &acp_thread::AcpThread, cx: &App) -> ThreadMetadata { - let session_id = thread.session_id().clone(); - let title = thread.title(); - let updated_at = Utc::now(); - - let agent_id = thread.connection().agent_id(); - - let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() { - None - } else { - Some(agent_id) - }; - - let folder_paths = { - let project = thread.project().read(cx); - let paths: Vec> = project - .visible_worktrees(cx) - .map(|worktree| worktree.read(cx).abs_path()) - .collect(); - PathList::new(&paths) - }; - - ThreadMetadata { - session_id, - agent_id, - title, - created_at: Some(updated_at), // handled by db `ON CONFLICT` - updated_at, - folder_paths, - } - } } impl Global for ThreadMetadataStore {} @@ -272,6 +305,12 @@ impl Domain for ThreadMetadataDb { db::static_connection!(ThreadMetadataDb, []); impl ThreadMetadataDb { + /// List allsidebar thread session IDs + pub fn list_ids(&self) -> anyhow::Result> { + self.select::>("SELECT session_id FROM sidebar_threads")?() + .map(|ids| ids.into_iter().map(|id| acp::SessionId::new(id)).collect()) + } + /// List all sidebar thread metadata, ordered by updated_at descending. pub fn list(&self) -> anyhow::Result> { self.select::( diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 4cdbe0dd272c6659b7a534afc0ef560eea93ca3b..560f5631573901bbb52689feb0e09dacefe63b66 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use crate::{ Agent, RemoveSelectedThread, agent_connection_store::AgentConnectionStore, - thread_history::ThreadHistory, + thread_history::ThreadHistory, thread_metadata_store::ThreadMetadataStore, }; use acp_thread::AgentSessionInfo; use agent::ThreadStore; @@ -19,8 +19,8 @@ use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::{AgentId, AgentServerStore}; use theme::ActiveTheme; use ui::{ - ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem, - PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, + ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, KeyBinding, + ListItem, PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height, }; use util::ResultExt as _; @@ -110,7 +110,7 @@ fn archive_empty_state_message( pub enum ThreadsArchiveViewEvent { Close, - OpenThread { + Unarchive { agent: Agent, session_info: AgentSessionInfo, }, @@ -135,6 +135,7 @@ pub struct ThreadsArchiveView { _subscriptions: Vec, selected_agent_menu: PopoverMenuHandle, _refresh_history_task: Task<()>, + _update_items_task: Option>, is_loading: bool, } @@ -179,6 +180,7 @@ impl ThreadsArchiveView { _subscriptions: vec![filter_editor_subscription], selected_agent_menu: PopoverMenuHandle::default(), _refresh_history_task: Task::ready(()), + _update_items_task: None, is_loading: true, }; this.set_selected_agent(Agent::NativeAgent, window, cx); @@ -241,42 +243,58 @@ impl ThreadsArchiveView { let query = self.filter_editor.read(cx).text(cx).to_lowercase(); let today = Local::now().naive_local().date(); - let mut items = Vec::with_capacity(sessions.len() + 5); - let mut current_bucket: Option = None; + self._update_items_task.take(); + let unarchived_ids_task = ThreadMetadataStore::global(cx).read(cx).list_ids(cx); + self._update_items_task = Some(cx.spawn(async move |this, cx| { + let unarchived_session_ids = unarchived_ids_task.await.unwrap_or_default(); - for session in sessions { - let highlight_positions = if !query.is_empty() { - let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or(""); - match fuzzy_match_positions(&query, title) { - Some(positions) => positions, - None => continue, + let mut items = Vec::with_capacity(sessions.len() + 5); + let mut current_bucket: Option = None; + + for session in sessions { + // Skip sessions that are shown in the sidebar + if unarchived_session_ids.contains(&session.session_id) { + continue; } - } else { - Vec::new() - }; - - let entry_bucket = session - .updated_at - .map(|timestamp| { - let entry_date = timestamp.with_timezone(&Local).naive_local().date(); - TimeBucket::from_dates(today, entry_date) - }) - .unwrap_or(TimeBucket::Older); - - if Some(entry_bucket) != current_bucket { - current_bucket = Some(entry_bucket); - items.push(ArchiveListItem::BucketSeparator(entry_bucket)); - } - items.push(ArchiveListItem::Entry { - session, - highlight_positions, - }); - } + let highlight_positions = if !query.is_empty() { + let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or(""); + match fuzzy_match_positions(&query, title) { + Some(positions) => positions, + None => continue, + } + } else { + Vec::new() + }; - self.list_state.reset(items.len()); - self.items = items; - cx.notify(); + let entry_bucket = session + .updated_at + .map(|timestamp| { + let entry_date = timestamp.with_timezone(&Local).naive_local().date(); + TimeBucket::from_dates(today, entry_date) + }) + .unwrap_or(TimeBucket::Older); + + if Some(entry_bucket) != current_bucket { + current_bucket = Some(entry_bucket); + items.push(ArchiveListItem::BucketSeparator(entry_bucket)); + } + + items.push(ArchiveListItem::Entry { + session, + highlight_positions, + }); + } + + this.update(cx, |this, cx| { + this.list_state.reset(items.len()); + this.items = items; + this.selection = None; + this.hovered_index = None; + cx.notify(); + }) + .ok(); + })); } fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context) { @@ -290,7 +308,7 @@ impl ThreadsArchiveView { cx.emit(ThreadsArchiveViewEvent::Close); } - fn open_thread( + fn unarchive_thread( &mut self, session_info: AgentSessionInfo, window: &mut Window, @@ -298,7 +316,7 @@ impl ThreadsArchiveView { ) { self.selection = None; self.reset_filter_editor_text(window, cx); - cx.emit(ThreadsArchiveViewEvent::OpenThread { + cx.emit(ThreadsArchiveViewEvent::Unarchive { agent: self.selected_agent.clone(), session_info, }); @@ -410,7 +428,7 @@ impl ThreadsArchiveView { let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else { return; }; - self.open_thread(session.clone(), window, cx); + self.unarchive_thread(session.clone(), window, cx); } fn render_list_entry( @@ -439,59 +457,54 @@ impl ThreadsArchiveView { session, highlight_positions, } => { - let is_selected = self.selection == Some(ix); + let id = SharedString::from(format!("archive-entry-{}", ix)); + + let is_focused = self.selection == Some(ix); let hovered = self.hovered_index == Some(ix); + + let project_names = session.work_dirs.as_ref().and_then(|paths| { + let paths_str = paths + .paths() + .iter() + .filter_map(|p| p.file_name()) + .filter_map(|name| name.to_str()) + .join(", "); + if paths_str.is_empty() { + None + } else { + Some(paths_str) + } + }); + let supports_delete = self .history .as_ref() .map(|h| h.read(cx).supports_delete()) .unwrap_or(false); + let title: SharedString = session.title.clone().unwrap_or_else(|| "Untitled".into()); + let session_info = session.clone(); let session_id_for_delete = session.session_id.clone(); let focus_handle = self.focus_handle.clone(); - let highlight_positions = highlight_positions.clone(); let timestamp = session .created_at .or(session.updated_at) .map(format_history_entry_timestamp); - let id = SharedString::from(format!("archive-entry-{}", ix)); - + let highlight_positions = highlight_positions.clone(); let title_label = if highlight_positions.is_empty() { - Label::new(title) - .size(LabelSize::Small) - .truncate() - .into_any_element() + Label::new(title).truncate().into_any_element() } else { HighlightedLabel::new(title, highlight_positions) - .size(LabelSize::Small) .truncate() .into_any_element() }; ListItem::new(id) - .toggle_state(is_selected) - .child( - h_flex() - .min_w_0() - .w_full() - .py_1() - .pl_0p5() - .pr_1p5() - .gap_2() - .justify_between() - .child(title_label) - .when(!(hovered && supports_delete), |this| { - this.when_some(timestamp, |this, ts| { - this.child( - Label::new(ts).size(LabelSize::Small).color(Color::Muted), - ) - }) - }), - ) + .focused(is_focused) .on_hover(cx.listener(move |this, is_hovered, _window, cx| { if *is_hovered { this.hovered_index = Some(ix); @@ -500,32 +513,84 @@ impl ThreadsArchiveView { } cx.notify(); })) - .end_slot::(if hovered && supports_delete { - Some( - IconButton::new("delete-thread", IconName::Trash) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in( - "Delete Thread", - &RemoveSelectedThread, - &focus_handle, - cx, + .child( + v_flex() + .min_w_0() + .w_full() + .py_1() + .pl_0p5() + .child(title_label) + .child( + h_flex() + .gap_1() + .when_some(timestamp, |this, ts| { + this.child( + Label::new(ts) + .size(LabelSize::Small) + .color(Color::Muted), ) - } - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.delete_thread(&session_id_for_delete, cx); - cx.stop_propagation(); - })), + }) + .when_some(project_names, |this, project| { + this.child( + Label::new("•") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5), + ) + .child( + Label::new(project) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ), + ) + .when(hovered || is_focused, |this| { + this.end_slot( + h_flex() + .pr_2p5() + .gap_0p5() + .child( + Button::new("unarchive-thread", "Unarchive") + .style(ButtonStyle::OutlinedGhost) + .label_size(LabelSize::Small) + .when(is_focused, |this| { + this.key_binding( + KeyBinding::for_action_in( + &menu::Confirm, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.unarchive_thread(session_info.clone(), window, cx); + })), + ) + .when(supports_delete, |this| { + this.child( + IconButton::new("delete-thread", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Delete Thread", + &RemoveSelectedThread, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.delete_thread(&session_id_for_delete, cx); + cx.stop_propagation(); + })), + ) + }), ) - } else { - None }) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_thread(session_info.clone(), window, cx); - })) .into_any_element() } } @@ -697,8 +762,7 @@ impl ThreadsArchiveView { .child( h_flex() .h(Tab::container_height(cx)) - .p_2() - .pr_1p5() + .px_1p5() .gap_1p5() .border_b_1() .border_color(cx.theme().colors().border) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 9f8f6d453ad485710c856ba874a3b5efb0558b89..5e359175ff96dee9b9885b82af2bc70096ead3f8 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -1,6 +1,5 @@ use acp_thread::ThreadStatus; use action_log::DiffStats; -use agent::ThreadStore; use agent_client_protocol::{self as acp}; use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; use agent_ui::threads_archive_view::{ @@ -1870,6 +1869,16 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { + // Eagerly save thread metadata so that the sidebar is updated immediately + ThreadMetadataStore::global(cx) + .update(cx, |store, cx| { + store.save( + ThreadMetadata::from_session_info(agent.id(), &session_info), + cx, + ) + }) + .detach_and_log_err(cx); + if let Some(path_list) = &session_info.work_dirs { if let Some(workspace) = self.find_current_workspace_for_path_list(path_list, cx) { self.activate_thread_locally(agent, session_info, &workspace, window, cx); @@ -2040,13 +2049,13 @@ impl Sidebar { } } - fn delete_thread( + fn archive_thread( &mut self, session_id: &acp::SessionId, window: &mut Window, cx: &mut Context, ) { - // If we're deleting the currently focused thread, move focus to the + // 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. @@ -2058,7 +2067,7 @@ impl Sidebar { // 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 deleting a thread in a non-active group. + // might be archiving a thread in a non-active group. let group_workspace = current_pos.and_then(|pos| { self.contents.entries[..pos] .iter() @@ -2133,15 +2142,6 @@ impl Sidebar { } } - let Some(thread_store) = ThreadStore::try_global(cx) else { - return; - }; - thread_store.update(cx, |store, cx| { - store - .delete_thread(session_id.clone(), cx) - .detach_and_log_err(cx); - }); - ThreadMetadataStore::global(cx) .update(cx, |store, cx| store.delete(session_id.clone(), cx)) .detach_and_log_err(cx); @@ -2163,7 +2163,7 @@ impl Sidebar { return; } let session_id = thread.session_info.session_id.clone(); - self.delete_thread(&session_id, window, cx); + self.archive_thread(&session_id, window, cx); } fn render_thread( @@ -2251,14 +2251,14 @@ impl Sidebar { }) .when(is_hovered && can_delete && !is_running, |this| { this.action_slot( - IconButton::new("delete-thread", IconName::Trash) + IconButton::new("archive-thread", IconName::Archive) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip({ let focus_handle = focus_handle.clone(); move |_window, cx| { Tooltip::for_action_in( - "Delete Thread", + "Archive Thread", &RemoveSelectedThread, &focus_handle, cx, @@ -2268,7 +2268,7 @@ impl Sidebar { .on_click({ let session_id = session_id_for_delete.clone(); cx.listener(move |this, _, window, cx| { - this.delete_thread(&session_id, window, cx); + this.archive_thread(&session_id, window, cx); }) }), ) @@ -2659,7 +2659,7 @@ impl Sidebar { ThreadsArchiveViewEvent::Close => { this.show_thread_list(window, cx); } - ThreadsArchiveViewEvent::OpenThread { + ThreadsArchiveViewEvent::Unarchive { agent, session_info, } => {