sidebar: Improve the archive view (#51863)

Danilo Leal , Bennet Bo Fenner , and cameron created

- 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 <bennetbo@gmx.de>
Co-authored-by: cameron <cameron.studdstreet@gmail.com>

Change summary

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(-)

Detailed changes

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<dyn fs::Fs>,

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<acp_thread::AcpThread>, 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<Arc<Path>> = 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<acp::SessionId, Subscription>,
@@ -119,6 +176,14 @@ impl ThreadMetadataStore {
         cx.global::<GlobalThreadMetadataStore>().0.clone()
     }
 
+    pub fn list_ids(&self, cx: &App) -> Task<Result<Vec<acp::SessionId>>> {
+        let db = self.db.clone();
+        cx.background_spawn(async move {
+            let s = db.list_ids()?;
+            Ok(s)
+        })
+    }
+
     pub fn list(&self, cx: &App) -> Task<Result<Vec<ThreadMetadata>>> {
         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<Arc<Path>> = 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<Vec<acp::SessionId>> {
+        self.select::<Arc<str>>("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<Vec<ThreadMetadata>> {
         self.select::<ThreadMetadata>(

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<gpui::Subscription>,
     selected_agent_menu: PopoverMenuHandle<ContextMenu>,
     _refresh_history_task: Task<()>,
+    _update_items_task: Option<Task<()>>,
     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<TimeBucket> = 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<TimeBucket> = 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<Self>) {
@@ -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::<IconButton>(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)

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<Self>,
     ) {
+        // 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<Self>,
     ) {
-        // 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,
                 } => {