sidebar: Rework archive feature (#52534)

Bennet Bo Fenner and Ben Brandt created

## Context

<!-- What does this PR do, and why? How is it expected to impact users?
     Not just what changed, but what motivated it and why this approach.

Link to Linear issue (e.g., ENG-123) or GitHub issue (e.g., Closes #456)
     if one exists — helps with traceability. -->

## How to Review

<!-- Help reviewers focus their attention:
- For small PRs: note what to focus on (e.g., "error handling in
foo.rs")
- For large PRs (>400 LOC): provide a guided tour — numbered list of
files/commits to read in order. (The `large-pr` label is applied
automatically.)
     - See the review process guidelines for comment conventions -->

## Self-Review Checklist

<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [ ] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>

Change summary

crates/agent/src/thread_store.rs              |   4 
crates/agent_ui/src/agent_connection_store.rs |   4 
crates/agent_ui/src/agent_ui.rs               |  11 
crates/agent_ui/src/conversation_view.rs      |   6 
crates/agent_ui/src/thread_import.rs          | 660 +++++++++++++++++
crates/agent_ui/src/thread_metadata_store.rs  | 806 ++++++++++++++------
crates/agent_ui/src/threads_archive_view.rs   | 711 +++++------------
crates/project/src/agent_server_store.rs      |   4 
crates/sidebar/src/sidebar.rs                 | 103 +-
crates/sidebar/src/sidebar_tests.rs           | 171 +++
crates/ui/src/components/ai/thread_item.rs    |  42 +
crates/util/src/path_list.rs                  |   5 
12 files changed, 1,704 insertions(+), 823 deletions(-)

Detailed changes

crates/agent/src/thread_store.rs 🔗

@@ -113,6 +113,10 @@ impl ThreadStore {
     pub fn entries(&self) -> impl Iterator<Item = DbThreadMetadata> + '_ {
         self.threads.iter().cloned()
     }
+
+    pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
+        self.threads.iter().map(|t| t.id.clone())
+    }
 }
 
 #[cfg(test)]

crates/agent_ui/src/agent_connection_store.rs 🔗

@@ -83,6 +83,10 @@ impl AgentConnectionStore {
         }
     }
 
+    pub fn project(&self) -> &Entity<Project> {
+        &self.project
+    }
+
     pub fn entry(&self, key: &Agent) -> Option<&Entity<AgentConnectionEntry>> {
         self.entries.get(key)
     }

crates/agent_ui/src/agent_ui.rs 🔗

@@ -33,6 +33,7 @@ mod text_thread_editor;
 mod text_thread_history;
 mod thread_history;
 mod thread_history_view;
+mod thread_import;
 pub mod thread_metadata_store;
 pub mod threads_archive_view;
 mod ui;
@@ -252,6 +253,16 @@ pub enum Agent {
     },
 }
 
+impl From<AgentId> for Agent {
+    fn from(id: AgentId) -> Self {
+        if id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
+            Self::NativeAgent
+        } else {
+            Self::Custom { id }
+        }
+    }
+}
+
 impl Agent {
     pub fn id(&self) -> AgentId {
         match self {

crates/agent_ui/src/conversation_view.rs 🔗

@@ -78,7 +78,7 @@ use crate::agent_diff::AgentDiff;
 use crate::entry_view_state::{EntryViewEvent, ViewEvent};
 use crate::message_editor::{MessageEditor, MessageEditorEvent};
 use crate::profile_selector::{ProfileProvider, ProfileSelector};
-use crate::thread_metadata_store::SidebarThreadMetadataStore;
+use crate::thread_metadata_store::ThreadMetadataStore;
 use crate::ui::{AgentNotification, AgentNotificationEvent};
 use crate::{
     Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce,
@@ -2571,7 +2571,7 @@ impl ConversationView {
         let task = history.update(cx, |history, cx| history.delete_session(&session_id, cx));
         task.detach_and_log_err(cx);
 
-        if let Some(store) = SidebarThreadMetadataStore::try_global(cx) {
+        if let Some(store) = ThreadMetadataStore::try_global(cx) {
             store.update(cx, |store, cx| store.delete(session_id.clone(), cx));
         }
     }
@@ -4258,7 +4258,7 @@ pub(crate) mod tests {
         cx.update(|cx| {
             let settings_store = SettingsStore::test(cx);
             cx.set_global(settings_store);
-            SidebarThreadMetadataStore::init_global(cx);
+            ThreadMetadataStore::init_global(cx);
             theme_settings::init(theme::LoadThemes::JustBase, cx);
             editor::init(cx);
             agent_panel::init(cx);

crates/agent_ui/src/thread_import.rs 🔗

@@ -0,0 +1,660 @@
+use acp_thread::AgentSessionListRequest;
+use agent::ThreadStore;
+use agent_client_protocol as acp;
+use chrono::Utc;
+use collections::HashSet;
+use fs::Fs;
+use futures::FutureExt as _;
+use gpui::{
+    App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent,
+    Render, SharedString, Task, WeakEntity, Window,
+};
+use notifications::status_toast::{StatusToast, ToastIcon};
+use project::{AgentId, AgentRegistryStore, AgentServerStore};
+use ui::{Checkbox, CommonAnimationExt as _, KeyBinding, ListItem, ListItemSpacing, prelude::*};
+use util::ResultExt;
+use workspace::{ModalView, MultiWorkspace, Workspace};
+
+use crate::{
+    Agent, AgentPanel,
+    agent_connection_store::AgentConnectionStore,
+    thread_metadata_store::{ThreadMetadata, ThreadMetadataStore},
+};
+
+#[derive(Clone)]
+struct AgentEntry {
+    agent_id: AgentId,
+    display_name: SharedString,
+    icon_path: Option<SharedString>,
+}
+
+pub struct ThreadImportModal {
+    focus_handle: FocusHandle,
+    workspace: WeakEntity<Workspace>,
+    multi_workspace: WeakEntity<MultiWorkspace>,
+    agent_entries: Vec<AgentEntry>,
+    unchecked_agents: HashSet<AgentId>,
+    is_importing: bool,
+    last_error: Option<SharedString>,
+}
+
+impl ThreadImportModal {
+    pub fn new(
+        agent_server_store: Entity<AgentServerStore>,
+        agent_registry_store: Entity<AgentRegistryStore>,
+        workspace: WeakEntity<Workspace>,
+        multi_workspace: WeakEntity<MultiWorkspace>,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let agent_entries = agent_server_store
+            .read(cx)
+            .external_agents()
+            .map(|agent_id| {
+                let display_name = agent_server_store
+                    .read(cx)
+                    .agent_display_name(agent_id)
+                    .or_else(|| {
+                        agent_registry_store
+                            .read(cx)
+                            .agent(agent_id)
+                            .map(|agent| agent.name().clone())
+                    })
+                    .unwrap_or_else(|| agent_id.0.clone());
+                let icon_path = agent_server_store
+                    .read(cx)
+                    .agent_icon(agent_id)
+                    .or_else(|| {
+                        agent_registry_store
+                            .read(cx)
+                            .agent(agent_id)
+                            .and_then(|agent| agent.icon_path().cloned())
+                    });
+
+                AgentEntry {
+                    agent_id: agent_id.clone(),
+                    display_name,
+                    icon_path,
+                }
+            })
+            .collect::<Vec<_>>();
+
+        Self {
+            focus_handle: cx.focus_handle(),
+            workspace,
+            multi_workspace,
+            agent_entries,
+            unchecked_agents: HashSet::default(),
+            is_importing: false,
+            last_error: None,
+        }
+    }
+
+    fn agent_ids(&self) -> Vec<AgentId> {
+        self.agent_entries
+            .iter()
+            .map(|entry| entry.agent_id.clone())
+            .collect()
+    }
+
+    fn set_agent_checked(&mut self, agent_id: AgentId, state: ToggleState, cx: &mut Context<Self>) {
+        match state {
+            ToggleState::Selected => {
+                self.unchecked_agents.remove(&agent_id);
+            }
+            ToggleState::Unselected | ToggleState::Indeterminate => {
+                self.unchecked_agents.insert(agent_id);
+            }
+        }
+        cx.notify();
+    }
+
+    fn toggle_agent_checked(&mut self, agent_id: AgentId, cx: &mut Context<Self>) {
+        if self.unchecked_agents.contains(&agent_id) {
+            self.unchecked_agents.remove(&agent_id);
+        } else {
+            self.unchecked_agents.insert(agent_id);
+        }
+        cx.notify();
+    }
+
+    fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+        cx.emit(DismissEvent);
+    }
+
+    fn import_threads(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context<Self>) {
+        if self.is_importing {
+            return;
+        }
+
+        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+            self.is_importing = false;
+            cx.notify();
+            return;
+        };
+
+        let stores = resolve_agent_connection_stores(&multi_workspace, cx);
+        if stores.is_empty() {
+            log::error!("Did not find any workspaces to import from");
+            self.is_importing = false;
+            cx.notify();
+            return;
+        }
+
+        self.is_importing = true;
+        self.last_error = None;
+        cx.notify();
+
+        let agent_ids = self
+            .agent_ids()
+            .into_iter()
+            .filter(|agent_id| !self.unchecked_agents.contains(agent_id))
+            .collect::<Vec<_>>();
+
+        let existing_sessions = ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entry_ids()
+            .collect::<HashSet<_>>();
+
+        let task = find_threads_to_import(agent_ids, existing_sessions, stores, cx);
+        cx.spawn(async move |this, cx| {
+            let result = task.await;
+            this.update(cx, |this, cx| match result {
+                Ok(threads) => {
+                    let imported_count = threads.len();
+                    ThreadMetadataStore::global(cx)
+                        .update(cx, |store, cx| store.save_all(threads, cx));
+                    this.is_importing = false;
+                    this.last_error = None;
+                    this.show_imported_threads_toast(imported_count, cx);
+                    cx.emit(DismissEvent);
+                }
+                Err(error) => {
+                    this.is_importing = false;
+                    this.last_error = Some(error.to_string().into());
+                    cx.notify();
+                }
+            })
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) {
+        let status_toast = if imported_count == 0 {
+            StatusToast::new("No threads found to import.", cx, |this, _cx| {
+                this.icon(ToastIcon::new(IconName::Info).color(Color::Info))
+            })
+        } else {
+            let message = if imported_count == 1 {
+                "Imported 1 thread.".to_string()
+            } else {
+                format!("Imported {imported_count} threads.")
+            };
+            StatusToast::new(message, cx, |this, _cx| {
+                this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
+            })
+        };
+
+        self.workspace
+            .update(cx, |workspace, cx| {
+                workspace.toggle_status_toast(status_toast, cx);
+            })
+            .log_err();
+    }
+}
+
+impl EventEmitter<DismissEvent> for ThreadImportModal {}
+
+impl Focusable for ThreadImportModal {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl ModalView for ThreadImportModal {}
+
+impl Render for ThreadImportModal {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let agent_rows = self
+            .agent_entries
+            .iter()
+            .enumerate()
+            .map(|(ix, entry)| {
+                let is_checked = !self.unchecked_agents.contains(&entry.agent_id);
+
+                ListItem::new(("thread-import-agent", ix))
+                    .inset(true)
+                    .spacing(ListItemSpacing::Sparse)
+                    .start_slot(
+                        Checkbox::new(
+                            ("thread-import-agent-checkbox", ix),
+                            if is_checked {
+                                ToggleState::Selected
+                            } else {
+                                ToggleState::Unselected
+                            },
+                        )
+                        .on_click({
+                            let agent_id = entry.agent_id.clone();
+                            cx.listener(move |this, state: &ToggleState, _window, cx| {
+                                this.set_agent_checked(agent_id.clone(), *state, cx);
+                            })
+                        }),
+                    )
+                    .on_click({
+                        let agent_id = entry.agent_id.clone();
+                        cx.listener(move |this, _event, _window, cx| {
+                            this.toggle_agent_checked(agent_id.clone(), cx);
+                        })
+                    })
+                    .child(
+                        h_flex()
+                            .w_full()
+                            .gap_2()
+                            .child(if let Some(icon_path) = entry.icon_path.clone() {
+                                Icon::from_external_svg(icon_path)
+                                    .color(Color::Muted)
+                                    .size(IconSize::Small)
+                            } else {
+                                Icon::new(IconName::Sparkle)
+                                    .color(Color::Muted)
+                                    .size(IconSize::Small)
+                            })
+                            .child(Label::new(entry.display_name.clone())),
+                    )
+            })
+            .collect::<Vec<_>>();
+
+        let has_agents = !self.agent_entries.is_empty();
+
+        v_flex()
+            .id("thread-import-modal")
+            .key_context("ThreadImportModal")
+            .w(rems(34.))
+            .elevation_3(cx)
+            .overflow_hidden()
+            .track_focus(&self.focus_handle)
+            .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(Self::import_threads))
+            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+                this.focus_handle.focus(window, cx);
+            }))
+            // Header
+            .child(
+                v_flex()
+                    .p_4()
+                    .pb_2()
+                    .gap_1()
+                    .child(Headline::new("Import Threads").size(HeadlineSize::Small))
+                    .child(
+                        Label::new(
+                            "Select the agents whose threads you'd like to import. \
+                             Imported threads will appear in your thread archive.",
+                        )
+                        .color(Color::Muted)
+                        .size(LabelSize::Small),
+                    ),
+            )
+            // Agent list
+            .child(
+                v_flex()
+                    .id("thread-import-agent-list")
+                    .px_2()
+                    .max_h(rems(20.))
+                    .overflow_y_scroll()
+                    .when(has_agents, |this| this.children(agent_rows))
+                    .when(!has_agents, |this| {
+                        this.child(
+                            div().p_4().child(
+                                Label::new("No ACP agents available.")
+                                    .color(Color::Muted)
+                                    .size(LabelSize::Small),
+                            ),
+                        )
+                    }),
+            )
+            // Footer
+            .child(
+                h_flex()
+                    .w_full()
+                    .p_3()
+                    .gap_2()
+                    .items_center()
+                    .border_t_1()
+                    .border_color(cx.theme().colors().border_variant)
+                    .child(div().flex_1().min_w_0().when_some(
+                        self.last_error.clone(),
+                        |this, error| {
+                            this.child(
+                                Label::new(error)
+                                    .size(LabelSize::Small)
+                                    .color(Color::Error)
+                                    .truncate(),
+                            )
+                        },
+                    ))
+                    .child(
+                        h_flex()
+                            .gap_2()
+                            .items_center()
+                            .when(self.is_importing, |this| {
+                                this.child(
+                                    Icon::new(IconName::ArrowCircle)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted)
+                                        .with_rotate_animation(2),
+                                )
+                            })
+                            .child(
+                                Button::new("import-threads", "Import Threads")
+                                    .disabled(self.is_importing || !has_agents)
+                                    .key_binding(
+                                        KeyBinding::for_action(&menu::Confirm, cx)
+                                            .map(|kb| kb.size(rems_from_px(12.))),
+                                    )
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        this.import_threads(&menu::Confirm, window, cx);
+                                    })),
+                            ),
+                    ),
+            )
+    }
+}
+
+fn resolve_agent_connection_stores(
+    multi_workspace: &Entity<MultiWorkspace>,
+    cx: &App,
+) -> Vec<Entity<AgentConnectionStore>> {
+    let mut stores = Vec::new();
+    let mut included_local_store = false;
+
+    for workspace in multi_workspace.read(cx).workspaces() {
+        let workspace = workspace.read(cx);
+        let project = workspace.project().read(cx);
+
+        // We only want to include scores from one local workspace, since we
+        // know that they live on the same machine
+        let include_store = if project.is_remote() {
+            true
+        } else if project.is_local() && !included_local_store {
+            included_local_store = true;
+            true
+        } else {
+            false
+        };
+
+        if !include_store {
+            continue;
+        }
+
+        if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+            stores.push(panel.read(cx).connection_store().clone());
+        }
+    }
+
+    stores
+}
+
+fn find_threads_to_import(
+    agent_ids: Vec<AgentId>,
+    existing_sessions: HashSet<acp::SessionId>,
+    stores: Vec<Entity<AgentConnectionStore>>,
+    cx: &mut App,
+) -> Task<anyhow::Result<Vec<ThreadMetadata>>> {
+    let mut wait_for_connection_tasks = Vec::new();
+
+    for store in stores {
+        for agent_id in agent_ids.clone() {
+            let agent = Agent::from(agent_id.clone());
+            let server = agent.server(<dyn Fs>::global(cx), ThreadStore::global(cx));
+            let entry = store.update(cx, |store, cx| store.request_connection(agent, server, cx));
+            wait_for_connection_tasks
+                .push(entry.read(cx).wait_for_connection().map(|s| (agent_id, s)));
+        }
+    }
+
+    let mut session_list_tasks = Vec::new();
+    cx.spawn(async move |cx| {
+        let results = futures::future::join_all(wait_for_connection_tasks).await;
+        for (agent, result) in results {
+            let Some(state) = result.log_err() else {
+                continue;
+            };
+            let Some(list) = cx.update(|cx| state.connection.session_list(cx)) else {
+                continue;
+            };
+            let task = cx.update(|cx| {
+                list.list_sessions(AgentSessionListRequest::default(), cx)
+                    .map(|r| (agent, r))
+            });
+            session_list_tasks.push(task);
+        }
+
+        let mut sessions_by_agent = Vec::new();
+        let results = futures::future::join_all(session_list_tasks).await;
+        for (agent_id, result) in results {
+            let Some(response) = result.log_err() else {
+                continue;
+            };
+            sessions_by_agent.push((agent_id, response.sessions));
+        }
+
+        Ok(collect_importable_threads(
+            sessions_by_agent,
+            existing_sessions,
+        ))
+    })
+}
+
+fn collect_importable_threads(
+    sessions_by_agent: Vec<(AgentId, Vec<acp_thread::AgentSessionInfo>)>,
+    mut existing_sessions: HashSet<acp::SessionId>,
+) -> Vec<ThreadMetadata> {
+    let mut to_insert = Vec::new();
+    for (agent_id, sessions) in sessions_by_agent {
+        for session in sessions {
+            if !existing_sessions.insert(session.session_id.clone()) {
+                continue;
+            }
+            let Some(folder_paths) = session.work_dirs else {
+                continue;
+            };
+            to_insert.push(ThreadMetadata {
+                session_id: session.session_id,
+                agent_id: agent_id.clone(),
+                title: session
+                    .title
+                    .unwrap_or_else(|| crate::DEFAULT_THREAD_TITLE.into()),
+                updated_at: session.updated_at.unwrap_or_else(|| Utc::now()),
+                created_at: session.created_at,
+                folder_paths,
+                archived: true,
+            });
+        }
+    }
+    to_insert
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use acp_thread::AgentSessionInfo;
+    use chrono::Utc;
+    use std::path::Path;
+    use workspace::PathList;
+
+    fn make_session(
+        session_id: &str,
+        title: Option<&str>,
+        work_dirs: Option<PathList>,
+        updated_at: Option<chrono::DateTime<Utc>>,
+        created_at: Option<chrono::DateTime<Utc>>,
+    ) -> AgentSessionInfo {
+        AgentSessionInfo {
+            session_id: acp::SessionId::new(session_id),
+            title: title.map(|t| SharedString::from(t.to_string())),
+            work_dirs,
+            updated_at,
+            created_at,
+            meta: None,
+        }
+    }
+
+    #[test]
+    fn test_collect_skips_sessions_already_in_existing_set() {
+        let existing = HashSet::from_iter(vec![acp::SessionId::new("existing-1")]);
+        let paths = PathList::new(&[Path::new("/project")]);
+
+        let sessions_by_agent = vec![(
+            AgentId::new("agent-a"),
+            vec![
+                make_session(
+                    "existing-1",
+                    Some("Already There"),
+                    Some(paths.clone()),
+                    None,
+                    None,
+                ),
+                make_session("new-1", Some("Brand New"), Some(paths), None, None),
+            ],
+        )];
+
+        let result = collect_importable_threads(sessions_by_agent, existing);
+
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].session_id.0.as_ref(), "new-1");
+        assert_eq!(result[0].title.as_ref(), "Brand New");
+    }
+
+    #[test]
+    fn test_collect_skips_sessions_without_work_dirs() {
+        let existing = HashSet::default();
+        let paths = PathList::new(&[Path::new("/project")]);
+
+        let sessions_by_agent = vec![(
+            AgentId::new("agent-a"),
+            vec![
+                make_session("has-dirs", Some("With Dirs"), Some(paths), None, None),
+                make_session("no-dirs", Some("No Dirs"), None, None, None),
+            ],
+        )];
+
+        let result = collect_importable_threads(sessions_by_agent, existing);
+
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].session_id.0.as_ref(), "has-dirs");
+    }
+
+    #[test]
+    fn test_collect_marks_all_imported_threads_as_archived() {
+        let existing = HashSet::default();
+        let paths = PathList::new(&[Path::new("/project")]);
+
+        let sessions_by_agent = vec![(
+            AgentId::new("agent-a"),
+            vec![
+                make_session("s1", Some("Thread 1"), Some(paths.clone()), None, None),
+                make_session("s2", Some("Thread 2"), Some(paths), None, None),
+            ],
+        )];
+
+        let result = collect_importable_threads(sessions_by_agent, existing);
+
+        assert_eq!(result.len(), 2);
+        assert!(result.iter().all(|t| t.archived));
+    }
+
+    #[test]
+    fn test_collect_assigns_correct_agent_id_per_session() {
+        let existing = HashSet::default();
+        let paths = PathList::new(&[Path::new("/project")]);
+
+        let sessions_by_agent = vec![
+            (
+                AgentId::new("agent-a"),
+                vec![make_session(
+                    "s1",
+                    Some("From A"),
+                    Some(paths.clone()),
+                    None,
+                    None,
+                )],
+            ),
+            (
+                AgentId::new("agent-b"),
+                vec![make_session("s2", Some("From B"), Some(paths), None, None)],
+            ),
+        ];
+
+        let result = collect_importable_threads(sessions_by_agent, existing);
+
+        assert_eq!(result.len(), 2);
+        let s1 = result
+            .iter()
+            .find(|t| t.session_id.0.as_ref() == "s1")
+            .unwrap();
+        let s2 = result
+            .iter()
+            .find(|t| t.session_id.0.as_ref() == "s2")
+            .unwrap();
+        assert_eq!(s1.agent_id.as_ref(), "agent-a");
+        assert_eq!(s2.agent_id.as_ref(), "agent-b");
+    }
+
+    #[test]
+    fn test_collect_deduplicates_across_agents() {
+        let existing = HashSet::default();
+        let paths = PathList::new(&[Path::new("/project")]);
+
+        let sessions_by_agent = vec![
+            (
+                AgentId::new("agent-a"),
+                vec![make_session(
+                    "shared-session",
+                    Some("From A"),
+                    Some(paths.clone()),
+                    None,
+                    None,
+                )],
+            ),
+            (
+                AgentId::new("agent-b"),
+                vec![make_session(
+                    "shared-session",
+                    Some("From B"),
+                    Some(paths),
+                    None,
+                    None,
+                )],
+            ),
+        ];
+
+        let result = collect_importable_threads(sessions_by_agent, existing);
+
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].session_id.0.as_ref(), "shared-session");
+        assert_eq!(
+            result[0].agent_id.as_ref(),
+            "agent-a",
+            "first agent encountered should win"
+        );
+    }
+
+    #[test]
+    fn test_collect_all_existing_returns_empty() {
+        let paths = PathList::new(&[Path::new("/project")]);
+        let existing =
+            HashSet::from_iter(vec![acp::SessionId::new("s1"), acp::SessionId::new("s2")]);
+
+        let sessions_by_agent = vec![(
+            AgentId::new("agent-a"),
+            vec![
+                make_session("s1", Some("T1"), Some(paths.clone()), None, None),
+                make_session("s2", Some("T2"), Some(paths), None, None),
+            ],
+        )];
+
+        let result = collect_importable_threads(sessions_by_agent, existing);
+        assert!(result.is_empty());
+    }
+}

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -1,11 +1,10 @@
 use std::{path::Path, sync::Arc};
 
-use acp_thread::AgentSessionInfo;
 use agent::{ThreadStore, ZED_AGENT_ID};
 use agent_client_protocol as acp;
 use anyhow::Context as _;
 use chrono::{DateTime, Utc};
-use collections::HashMap;
+use collections::{HashMap, HashSet};
 use db::{
     sqlez::{
         bindable::Column, domain::Domain, statement::Statement,
@@ -24,7 +23,7 @@ use workspace::PathList;
 use crate::DEFAULT_THREAD_TITLE;
 
 pub fn init(cx: &mut App) {
-    SidebarThreadMetadataStore::init_global(cx);
+    ThreadMetadataStore::init_global(cx);
 
     if cx.has_flag::<AgentV2FeatureFlag>() {
         migrate_thread_metadata(cx);
@@ -38,68 +37,59 @@ pub fn init(cx: &mut App) {
 }
 
 /// Migrate existing thread metadata from native agent thread store to the new metadata storage.
-/// We migrate the last 10 threads per project and skip threads that do not have a project.
+/// We skip migrating threads that do not have a project.
 ///
 /// TODO: Remove this after N weeks of shipping the sidebar
 fn migrate_thread_metadata(cx: &mut App) {
-    const MAX_MIGRATED_THREADS_PER_PROJECT: usize = 10;
-
-    let store = SidebarThreadMetadataStore::global(cx);
+    let store = ThreadMetadataStore::global(cx);
     let db = store.read(cx).db.clone();
 
     cx.spawn(async move |cx| {
-        if !db.is_empty()? {
-            return Ok::<(), anyhow::Error>(());
-        }
-
-        let metadata = store.read_with(cx, |_store, app| {
-            let mut migrated_threads_per_project = HashMap::default();
+        let existing_entries = db.list_ids()?.into_iter().collect::<HashSet<_>>();
 
-            ThreadStore::global(app)
-                .read(app)
+        let to_migrate = store.read_with(cx, |_store, cx| {
+            ThreadStore::global(cx)
+                .read(cx)
                 .entries()
                 .filter_map(|entry| {
-                    if entry.folder_paths.is_empty() {
-                        return None;
-                    }
-
-                    let migrated_thread_count = migrated_threads_per_project
-                        .entry(entry.folder_paths.clone())
-                        .or_insert(0);
-                    if *migrated_thread_count >= MAX_MIGRATED_THREADS_PER_PROJECT {
+                    if existing_entries.contains(&entry.id.0) || entry.folder_paths.is_empty() {
                         return None;
                     }
-                    *migrated_thread_count += 1;
 
                     Some(ThreadMetadata {
                         session_id: entry.id,
-                        agent_id: None,
+                        agent_id: ZED_AGENT_ID.clone(),
                         title: entry.title,
                         updated_at: entry.updated_at,
                         created_at: entry.created_at,
                         folder_paths: entry.folder_paths,
+                        archived: true,
                     })
                 })
                 .collect::<Vec<_>>()
         });
 
-        log::info!("Migrating {} thread store entries", metadata.len());
+        if to_migrate.is_empty() {
+            return anyhow::Ok(());
+        }
+
+        log::info!("Migrating {} thread store entries", to_migrate.len());
 
         // Manually save each entry to the database and call reload, otherwise
         // we'll end up triggering lots of reloads after each save
-        for entry in metadata {
+        for entry in to_migrate {
             db.save(entry).await?;
         }
 
         log::info!("Finished migrating thread store entries");
 
         let _ = store.update(cx, |store, cx| store.reload(cx));
-        Ok(())
+        anyhow::Ok(())
     })
     .detach_and_log_err(cx);
 }
 
-struct GlobalThreadMetadataStore(Entity<SidebarThreadMetadataStore>);
+struct GlobalThreadMetadataStore(Entity<ThreadMetadataStore>);
 impl Global for GlobalThreadMetadataStore {}
 
 /// Lightweight metadata for any thread (native or ACP), enough to populate
@@ -107,37 +97,20 @@ impl Global for GlobalThreadMetadataStore {}
 #[derive(Debug, Clone, PartialEq)]
 pub struct ThreadMetadata {
     pub session_id: acp::SessionId,
-    /// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents.
-    pub agent_id: Option<AgentId>,
+    pub agent_id: AgentId,
     pub title: SharedString,
     pub updated_at: DateTime<Utc>,
     pub created_at: Option<DateTime<Utc>>,
     pub folder_paths: PathList,
+    pub archived: bool,
 }
 
 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 {
+    pub fn from_thread(
+        is_archived: bool,
+        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
@@ -147,12 +120,6 @@ impl ThreadMetadata {
 
         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
@@ -169,17 +136,17 @@ impl ThreadMetadata {
             created_at: Some(updated_at), // handled by db `ON CONFLICT`
             updated_at,
             folder_paths,
+            archived: is_archived,
         }
     }
 }
 
-/// The store holds all metadata needed to show threads in the sidebar.
-/// Effectively, all threads stored in here are "non-archived".
+/// The store holds all metadata needed to show threads in the sidebar/the archive.
 ///
 /// Automatically listens to AcpThread events and updates metadata if it has changed.
-pub struct SidebarThreadMetadataStore {
+pub struct ThreadMetadataStore {
     db: ThreadMetadataDb,
-    threads: Vec<ThreadMetadata>,
+    threads: HashMap<acp::SessionId, ThreadMetadata>,
     threads_by_paths: HashMap<PathList, Vec<ThreadMetadata>>,
     reload_task: Option<Shared<Task<()>>>,
     session_subscriptions: HashMap<acp::SessionId, Subscription>,
@@ -202,7 +169,7 @@ impl DbOperation {
     }
 }
 
-impl SidebarThreadMetadataStore {
+impl ThreadMetadataStore {
     #[cfg(not(any(test, feature = "test-support")))]
     pub fn init_global(cx: &mut App) {
         if cx.has_global::<Self>() {
@@ -237,14 +204,22 @@ impl SidebarThreadMetadataStore {
         self.threads.is_empty()
     }
 
+    /// Returns all thread IDs.
+    pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
+        self.threads.keys().cloned()
+    }
+
+    /// Returns all threads.
     pub fn entries(&self) -> impl Iterator<Item = ThreadMetadata> + '_ {
-        self.threads.iter().cloned()
+        self.threads.values().cloned()
     }
 
-    pub fn entry_ids(&self) -> impl Iterator<Item = acp::SessionId> + '_ {
-        self.threads.iter().map(|thread| thread.session_id.clone())
+    /// Returns all archived threads.
+    pub fn archived_entries(&self) -> impl Iterator<Item = ThreadMetadata> + '_ {
+        self.entries().filter(|t| t.archived)
     }
 
+    /// Returns all threads for the given path list, excluding archived threads.
     pub fn entries_for_path(
         &self,
         path_list: &PathList,
@@ -253,6 +228,7 @@ impl SidebarThreadMetadataStore {
             .get(path_list)
             .into_iter()
             .flatten()
+            .filter(|s| !s.archived)
             .cloned()
     }
 
@@ -278,7 +254,7 @@ impl SidebarThreadMetadataStore {
                             .entry(row.folder_paths.clone())
                             .or_default()
                             .push(row.clone());
-                        this.threads.push(row);
+                        this.threads.insert(row.session_id.clone(), row);
                     }
 
                     cx.notify();
@@ -290,6 +266,18 @@ impl SidebarThreadMetadataStore {
         reload_task
     }
 
+    pub fn save_all(&mut self, metadata: Vec<ThreadMetadata>, cx: &mut Context<Self>) {
+        if !cx.has_flag::<AgentV2FeatureFlag>() {
+            return;
+        }
+
+        for metadata in metadata {
+            self.pending_thread_ops_tx
+                .try_send(DbOperation::Insert(metadata))
+                .log_err();
+        }
+    }
+
     pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context<Self>) {
         if !cx.has_flag::<AgentV2FeatureFlag>() {
             return;
@@ -300,6 +288,36 @@ impl SidebarThreadMetadataStore {
             .log_err();
     }
 
+    pub fn archive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
+        self.update_archived(session_id, true, cx);
+    }
+
+    pub fn unarchive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
+        self.update_archived(session_id, false, cx);
+    }
+
+    fn update_archived(
+        &mut self,
+        session_id: &acp::SessionId,
+        archived: bool,
+        cx: &mut Context<Self>,
+    ) {
+        if !cx.has_flag::<AgentV2FeatureFlag>() {
+            return;
+        }
+
+        if let Some(thread) = self.threads.get(session_id) {
+            self.save(
+                ThreadMetadata {
+                    archived,
+                    ..thread.clone()
+                },
+                cx,
+            );
+            cx.notify();
+        }
+    }
+
     pub fn delete(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
         if !cx.has_flag::<AgentV2FeatureFlag>() {
             return;
@@ -377,7 +395,7 @@ impl SidebarThreadMetadataStore {
 
         let mut this = Self {
             db,
-            threads: Vec::new(),
+            threads: HashMap::default(),
             threads_by_paths: HashMap::default(),
             reload_task: None,
             session_subscriptions: HashMap::default(),
@@ -422,7 +440,12 @@ impl SidebarThreadMetadataStore {
             | acp_thread::AcpThreadEvent::Error
             | acp_thread::AcpThreadEvent::LoadError(_)
             | acp_thread::AcpThreadEvent::Refusal => {
-                let metadata = ThreadMetadata::from_thread(&thread, cx);
+                let is_archived = self
+                    .threads
+                    .get(thread.read(cx).session_id())
+                    .map(|t| t.archived)
+                    .unwrap_or(false);
+                let metadata = ThreadMetadata::from_thread(is_archived, &thread, cx);
                 self.save(metadata, cx);
             }
             _ => {}
@@ -430,38 +453,43 @@ impl SidebarThreadMetadataStore {
     }
 }
 
-impl Global for SidebarThreadMetadataStore {}
+impl Global for ThreadMetadataStore {}
 
 struct ThreadMetadataDb(ThreadSafeConnection);
 
 impl Domain for ThreadMetadataDb {
     const NAME: &str = stringify!(ThreadMetadataDb);
 
-    const MIGRATIONS: &[&str] = &[sql!(
-        CREATE TABLE IF NOT EXISTS sidebar_threads(
-            session_id TEXT PRIMARY KEY,
-            agent_id TEXT,
-            title TEXT NOT NULL,
-            updated_at TEXT NOT NULL,
-            created_at TEXT,
-            folder_paths TEXT,
-            folder_paths_order TEXT
-        ) STRICT;
-    )];
+    const MIGRATIONS: &[&str] = &[
+        sql!(
+            CREATE TABLE IF NOT EXISTS sidebar_threads(
+                session_id TEXT PRIMARY KEY,
+                agent_id TEXT,
+                title TEXT NOT NULL,
+                updated_at TEXT NOT NULL,
+                created_at TEXT,
+                folder_paths TEXT,
+                folder_paths_order TEXT
+            ) STRICT;
+        ),
+        sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0),
+    ];
 }
 
 db::static_connection!(ThreadMetadataDb, []);
 
 impl ThreadMetadataDb {
-    pub fn is_empty(&self) -> anyhow::Result<bool> {
-        self.select::<i64>("SELECT COUNT(*) FROM sidebar_threads")?()
-            .map(|counts| counts.into_iter().next().unwrap_or_default() == 0)
+    pub fn list_ids(&self) -> anyhow::Result<Vec<Arc<str>>> {
+        self.select::<Arc<str>>(
+            "SELECT session_id FROM sidebar_threads \
+             ORDER BY updated_at DESC",
+        )?()
     }
 
     /// List all sidebar thread metadata, ordered by updated_at descending.
     pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
         self.select::<ThreadMetadata>(
-            "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order \
+            "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived \
              FROM sidebar_threads \
              ORDER BY updated_at DESC"
         )?()
@@ -470,7 +498,11 @@ impl ThreadMetadataDb {
     /// Upsert metadata for a thread.
     pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> {
         let id = row.session_id.0.clone();
-        let agent_id = row.agent_id.as_ref().map(|id| id.0.to_string());
+        let agent_id = if row.agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
+            None
+        } else {
+            Some(row.agent_id.to_string())
+        };
         let title = row.title.to_string();
         let updated_at = row.updated_at.to_rfc3339();
         let created_at = row.created_at.map(|dt| dt.to_rfc3339());
@@ -480,16 +512,18 @@ impl ThreadMetadataDb {
         } else {
             (Some(serialized.paths), Some(serialized.order))
         };
+        let archived = row.archived;
 
         self.write(move |conn| {
-            let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order) \
-                       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
+            let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order, archived) \
+                       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
                        ON CONFLICT(session_id) DO UPDATE SET \
                            agent_id = excluded.agent_id, \
                            title = excluded.title, \
                            updated_at = excluded.updated_at, \
                            folder_paths = excluded.folder_paths, \
-                           folder_paths_order = excluded.folder_paths_order";
+                           folder_paths_order = excluded.folder_paths_order, \
+                           archived = excluded.archived";
             let mut stmt = Statement::prepare(conn, sql)?;
             let mut i = stmt.bind(&id, 1)?;
             i = stmt.bind(&agent_id, i)?;
@@ -497,7 +531,8 @@ impl ThreadMetadataDb {
             i = stmt.bind(&updated_at, i)?;
             i = stmt.bind(&created_at, i)?;
             i = stmt.bind(&folder_paths, i)?;
-            stmt.bind(&folder_paths_order, i)?;
+            i = stmt.bind(&folder_paths_order, i)?;
+            stmt.bind(&archived, i)?;
             stmt.exec()
         })
         .await
@@ -526,6 +561,11 @@ impl Column for ThreadMetadata {
         let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
         let (folder_paths_order_str, next): (Option<String>, i32) =
             Column::column(statement, next)?;
+        let (archived, next): (bool, i32) = Column::column(statement, next)?;
+
+        let agent_id = agent_id
+            .map(|id| AgentId::new(id))
+            .unwrap_or(ZED_AGENT_ID.clone());
 
         let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
         let created_at = created_at_str
@@ -546,11 +586,12 @@ impl Column for ThreadMetadata {
         Ok((
             ThreadMetadata {
                 session_id: acp::SessionId::new(id),
-                agent_id: agent_id.map(|id| AgentId::new(id)),
+                agent_id,
                 title: title.into(),
                 updated_at,
                 created_at,
                 folder_paths,
+                archived,
             },
             next,
         ))
@@ -599,8 +640,9 @@ mod tests {
         folder_paths: PathList,
     ) -> ThreadMetadata {
         ThreadMetadata {
+            archived: false,
             session_id: acp::SessionId::new(session_id),
-            agent_id: None,
+            agent_id: agent::ZED_AGENT_ID.clone(),
             title: title.to_string().into(),
             updated_at,
             created_at: Some(updated_at),
@@ -643,20 +685,22 @@ mod tests {
             let settings_store = settings::SettingsStore::test(cx);
             cx.set_global(settings_store);
             cx.update_flags(true, vec!["agent-v2".to_string()]);
-            SidebarThreadMetadataStore::init_global(cx);
+            ThreadMetadataStore::init_global(cx);
         });
 
         cx.run_until_parked();
 
         cx.update(|cx| {
-            let store = SidebarThreadMetadataStore::global(cx);
+            let store = ThreadMetadataStore::global(cx);
             let store = store.read(cx);
 
             let entry_ids = store
                 .entry_ids()
                 .map(|session_id| session_id.0.to_string())
                 .collect::<Vec<_>>();
-            assert_eq!(entry_ids, vec!["session-1", "session-2"]);
+            assert_eq!(entry_ids.len(), 2);
+            assert!(entry_ids.contains(&"session-1".to_string()));
+            assert!(entry_ids.contains(&"session-2".to_string()));
 
             let first_path_entries = store
                 .entries_for_path(&first_paths)
@@ -678,7 +722,7 @@ mod tests {
             let settings_store = settings::SettingsStore::test(cx);
             cx.set_global(settings_store);
             cx.update_flags(true, vec!["agent-v2".to_string()]);
-            SidebarThreadMetadataStore::init_global(cx);
+            ThreadMetadataStore::init_global(cx);
         });
 
         let first_paths = PathList::new(&[Path::new("/project-a")]);
@@ -701,7 +745,7 @@ mod tests {
         );
 
         cx.update(|cx| {
-            let store = SidebarThreadMetadataStore::global(cx);
+            let store = ThreadMetadataStore::global(cx);
             store.update(cx, |store, cx| {
                 store.save(initial_metadata, cx);
                 store.save(second_metadata, cx);
@@ -711,7 +755,7 @@ mod tests {
         cx.run_until_parked();
 
         cx.update(|cx| {
-            let store = SidebarThreadMetadataStore::global(cx);
+            let store = ThreadMetadataStore::global(cx);
             let store = store.read(cx);
 
             let first_path_entries = store
@@ -735,7 +779,7 @@ mod tests {
         );
 
         cx.update(|cx| {
-            let store = SidebarThreadMetadataStore::global(cx);
+            let store = ThreadMetadataStore::global(cx);
             store.update(cx, |store, cx| {
                 store.save(moved_metadata, cx);
             });
@@ -744,14 +788,16 @@ mod tests {
         cx.run_until_parked();
 
         cx.update(|cx| {
-            let store = SidebarThreadMetadataStore::global(cx);
+            let store = ThreadMetadataStore::global(cx);
             let store = store.read(cx);
 
             let entry_ids = store
                 .entry_ids()
                 .map(|session_id| session_id.0.to_string())
                 .collect::<Vec<_>>();
-            assert_eq!(entry_ids, vec!["session-1", "session-2"]);
+            assert_eq!(entry_ids.len(), 2);
+            assert!(entry_ids.contains(&"session-1".to_string()));
+            assert!(entry_ids.contains(&"session-2".to_string()));
 
             let first_path_entries = store
                 .entries_for_path(&first_paths)
@@ -763,11 +809,13 @@ mod tests {
                 .entries_for_path(&second_paths)
                 .map(|entry| entry.session_id.0.to_string())
                 .collect::<Vec<_>>();
-            assert_eq!(second_path_entries, vec!["session-1", "session-2"]);
+            assert_eq!(second_path_entries.len(), 2);
+            assert!(second_path_entries.contains(&"session-1".to_string()));
+            assert!(second_path_entries.contains(&"session-2".to_string()));
         });
 
         cx.update(|cx| {
-            let store = SidebarThreadMetadataStore::global(cx);
+            let store = ThreadMetadataStore::global(cx);
             store.update(cx, |store, cx| {
                 store.delete(acp::SessionId::new("session-2"), cx);
             });
@@ -776,7 +824,7 @@ mod tests {
         cx.run_until_parked();
 
         cx.update(|cx| {
-            let store = SidebarThreadMetadataStore::global(cx);
+            let store = ThreadMetadataStore::global(cx);
             let store = store.read(cx);
 
             let entry_ids = store
@@ -794,61 +842,72 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_migrate_thread_metadata(cx: &mut TestAppContext) {
+    async fn test_migrate_thread_metadata_migrates_only_missing_threads(cx: &mut TestAppContext) {
         cx.update(|cx| {
             ThreadStore::init_global(cx);
-            SidebarThreadMetadataStore::init_global(cx);
-        });
-
-        // Verify the cache is empty before migration
-        let list = cx.update(|cx| {
-            let store = SidebarThreadMetadataStore::global(cx);
-            store.read(cx).entries().collect::<Vec<_>>()
+            ThreadMetadataStore::init_global(cx);
         });
-        assert_eq!(list.len(), 0);
 
         let project_a_paths = PathList::new(&[Path::new("/project-a")]);
         let project_b_paths = PathList::new(&[Path::new("/project-b")]);
         let now = Utc::now();
 
-        for index in 0..12 {
-            let updated_at = now + chrono::Duration::seconds(index as i64);
-            let session_id = format!("project-a-session-{index}");
-            let title = format!("Project A Thread {index}");
+        let existing_metadata = ThreadMetadata {
+            session_id: acp::SessionId::new("a-session-0"),
+            agent_id: agent::ZED_AGENT_ID.clone(),
+            title: "Existing Metadata".into(),
+            updated_at: now - chrono::Duration::seconds(10),
+            created_at: Some(now - chrono::Duration::seconds(10)),
+            folder_paths: project_a_paths.clone(),
+            archived: false,
+        };
 
-            let save_task = cx.update(|cx| {
-                let thread_store = ThreadStore::global(cx);
-                let session_id = session_id.clone();
-                let title = title.clone();
-                let project_a_paths = project_a_paths.clone();
-                thread_store.update(cx, |store, cx| {
-                    store.save_thread(
-                        acp::SessionId::new(session_id),
-                        make_db_thread(&title, updated_at),
-                        project_a_paths,
-                        cx,
-                    )
-                })
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store.update(cx, |store, cx| {
+                store.save(existing_metadata, cx);
             });
-            save_task.await.unwrap();
-            cx.run_until_parked();
-        }
+        });
+        cx.run_until_parked();
 
-        for index in 0..3 {
-            let updated_at = now + chrono::Duration::seconds(100 + index as i64);
-            let session_id = format!("project-b-session-{index}");
-            let title = format!("Project B Thread {index}");
+        let threads_to_save = vec![
+            (
+                "a-session-0",
+                "Thread A0 From Native Store",
+                project_a_paths.clone(),
+                now,
+            ),
+            (
+                "a-session-1",
+                "Thread A1",
+                project_a_paths.clone(),
+                now + chrono::Duration::seconds(1),
+            ),
+            (
+                "b-session-0",
+                "Thread B0",
+                project_b_paths.clone(),
+                now + chrono::Duration::seconds(2),
+            ),
+            (
+                "projectless",
+                "Projectless",
+                PathList::default(),
+                now + chrono::Duration::seconds(3),
+            ),
+        ];
 
+        for (session_id, title, paths, updated_at) in &threads_to_save {
             let save_task = cx.update(|cx| {
                 let thread_store = ThreadStore::global(cx);
-                let session_id = session_id.clone();
-                let title = title.clone();
-                let project_b_paths = project_b_paths.clone();
+                let session_id = session_id.to_string();
+                let title = title.to_string();
+                let paths = paths.clone();
                 thread_store.update(cx, |store, cx| {
                     store.save_thread(
                         acp::SessionId::new(session_id),
-                        make_db_thread(&title, updated_at),
-                        project_b_paths,
+                        make_db_thread(&title, *updated_at),
+                        paths,
                         cx,
                     )
                 })
@@ -857,130 +916,87 @@ mod tests {
             cx.run_until_parked();
         }
 
-        let save_projectless = cx.update(|cx| {
-            let thread_store = ThreadStore::global(cx);
-            thread_store.update(cx, |store, cx| {
-                store.save_thread(
-                    acp::SessionId::new("projectless-session"),
-                    make_db_thread("Projectless Thread", now + chrono::Duration::seconds(200)),
-                    PathList::default(),
-                    cx,
-                )
-            })
-        });
-        save_projectless.await.unwrap();
+        cx.update(|cx| migrate_thread_metadata(cx));
         cx.run_until_parked();
 
-        // Run migration
-        cx.update(|cx| {
-            migrate_thread_metadata(cx);
-        });
-
-        cx.run_until_parked();
-
-        // Verify the metadata was migrated, limited to 10 per project, and
-        // projectless threads were skipped.
         let list = cx.update(|cx| {
-            let store = SidebarThreadMetadataStore::global(cx);
+            let store = ThreadMetadataStore::global(cx);
             store.read(cx).entries().collect::<Vec<_>>()
         });
-        assert_eq!(list.len(), 13);
 
+        assert_eq!(list.len(), 3);
         assert!(
             list.iter()
-                .all(|metadata| !metadata.folder_paths.is_empty())
-        );
-        assert!(
-            list.iter()
-                .all(|metadata| metadata.session_id.0.as_ref() != "projectless-session")
+                .all(|metadata| metadata.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref())
         );
 
-        let project_a_entries = list
+        let existing_metadata = list
+            .iter()
+            .find(|metadata| metadata.session_id.0.as_ref() == "a-session-0")
+            .unwrap();
+        assert_eq!(existing_metadata.title.as_ref(), "Existing Metadata");
+        assert!(!existing_metadata.archived);
+
+        let migrated_session_ids = list
             .iter()
-            .filter(|metadata| metadata.folder_paths == project_a_paths)
+            .map(|metadata| metadata.session_id.0.as_ref())
             .collect::<Vec<_>>();
-        assert_eq!(project_a_entries.len(), 10);
-        assert_eq!(
-            project_a_entries
-                .iter()
-                .map(|metadata| metadata.session_id.0.as_ref())
-                .collect::<Vec<_>>(),
-            vec![
-                "project-a-session-11",
-                "project-a-session-10",
-                "project-a-session-9",
-                "project-a-session-8",
-                "project-a-session-7",
-                "project-a-session-6",
-                "project-a-session-5",
-                "project-a-session-4",
-                "project-a-session-3",
-                "project-a-session-2",
-            ]
-        );
-        assert!(
-            project_a_entries
-                .iter()
-                .all(|metadata| metadata.agent_id.is_none())
-        );
+        assert!(migrated_session_ids.contains(&"a-session-1"));
+        assert!(migrated_session_ids.contains(&"b-session-0"));
+        assert!(!migrated_session_ids.contains(&"projectless"));
 
-        let project_b_entries = list
+        let migrated_entries = list
             .iter()
-            .filter(|metadata| metadata.folder_paths == project_b_paths)
+            .filter(|metadata| metadata.session_id.0.as_ref() != "a-session-0")
             .collect::<Vec<_>>();
-        assert_eq!(project_b_entries.len(), 3);
-        assert_eq!(
-            project_b_entries
-                .iter()
-                .map(|metadata| metadata.session_id.0.as_ref())
-                .collect::<Vec<_>>(),
-            vec![
-                "project-b-session-2",
-                "project-b-session-1",
-                "project-b-session-0",
-            ]
-        );
         assert!(
-            project_b_entries
+            migrated_entries
                 .iter()
-                .all(|metadata| metadata.agent_id.is_none())
+                .all(|metadata| !metadata.folder_paths.is_empty())
         );
+        assert!(migrated_entries.iter().all(|metadata| metadata.archived));
     }
 
     #[gpui::test]
-    async fn test_migrate_thread_metadata_skips_when_data_exists(cx: &mut TestAppContext) {
+    async fn test_migrate_thread_metadata_noops_when_all_threads_already_exist(
+        cx: &mut TestAppContext,
+    ) {
         cx.update(|cx| {
             ThreadStore::init_global(cx);
-            SidebarThreadMetadataStore::init_global(cx);
+            ThreadMetadataStore::init_global(cx);
         });
 
-        // Pre-populate the metadata store with existing data
+        let project_paths = PathList::new(&[Path::new("/project-a")]);
+        let existing_updated_at = Utc::now();
+
         let existing_metadata = ThreadMetadata {
             session_id: acp::SessionId::new("existing-session"),
-            agent_id: None,
-            title: "Existing Thread".into(),
-            updated_at: Utc::now(),
-            created_at: Some(Utc::now()),
-            folder_paths: PathList::default(),
+            agent_id: agent::ZED_AGENT_ID.clone(),
+            title: "Existing Metadata".into(),
+            updated_at: existing_updated_at,
+            created_at: Some(existing_updated_at),
+            folder_paths: project_paths.clone(),
+            archived: false,
         };
 
         cx.update(|cx| {
-            let store = SidebarThreadMetadataStore::global(cx);
+            let store = ThreadMetadataStore::global(cx);
             store.update(cx, |store, cx| {
                 store.save(existing_metadata, cx);
             });
         });
-
         cx.run_until_parked();
 
-        // Add an entry to native thread store that should NOT be migrated
         let save_task = cx.update(|cx| {
             let thread_store = ThreadStore::global(cx);
             thread_store.update(cx, |store, cx| {
                 store.save_thread(
-                    acp::SessionId::new("native-session"),
-                    make_db_thread("Native Thread", Utc::now()),
-                    PathList::default(),
+                    acp::SessionId::new("existing-session"),
+                    make_db_thread(
+                        "Updated Native Thread Title",
+                        existing_updated_at + chrono::Duration::seconds(1),
+                    ),
+                    project_paths.clone(),
                     cx,
                 )
             })
@@ -988,22 +1004,17 @@ mod tests {
         save_task.await.unwrap();
         cx.run_until_parked();
 
-        // Run migration - should skip because metadata store is not empty
-        cx.update(|cx| {
-            migrate_thread_metadata(cx);
-        });
-
+        cx.update(|cx| migrate_thread_metadata(cx));
         cx.run_until_parked();
 
-        // Verify only the existing metadata is present (migration was skipped)
         let list = cx.update(|cx| {
-            let store = SidebarThreadMetadataStore::global(cx);
+            let store = ThreadMetadataStore::global(cx);
             store.read(cx).entries().collect::<Vec<_>>()
         });
+
         assert_eq!(list.len(), 1);
         assert_eq!(list[0].session_id.0.as_ref(), "existing-session");
     }
-
     #[gpui::test]
     async fn test_empty_thread_metadata_deleted_when_thread_released(cx: &mut TestAppContext) {
         cx.update(|cx| {
@@ -1011,7 +1022,7 @@ mod tests {
             cx.set_global(settings_store);
             cx.update_flags(true, vec!["agent-v2".to_string()]);
             ThreadStore::init_global(cx);
-            SidebarThreadMetadataStore::init_global(cx);
+            ThreadMetadataStore::init_global(cx);
         });
 
         let fs = FakeFs::new(cx.executor());
@@ -1036,7 +1047,7 @@ mod tests {
         cx.run_until_parked();
 
         let metadata_ids = cx.update(|cx| {
-            SidebarThreadMetadataStore::global(cx)
+            ThreadMetadataStore::global(cx)
                 .read(cx)
                 .entry_ids()
                 .collect::<Vec<_>>()
@@ -1049,7 +1060,7 @@ mod tests {
         cx.run_until_parked();
 
         let metadata_ids = cx.update(|cx| {
-            SidebarThreadMetadataStore::global(cx)
+            ThreadMetadataStore::global(cx)
                 .read(cx)
                 .entry_ids()
                 .collect::<Vec<_>>()
@@ -1067,7 +1078,7 @@ mod tests {
             cx.set_global(settings_store);
             cx.update_flags(true, vec!["agent-v2".to_string()]);
             ThreadStore::init_global(cx);
-            SidebarThreadMetadataStore::init_global(cx);
+            ThreadMetadataStore::init_global(cx);
         });
 
         let fs = FakeFs::new(cx.executor());
@@ -1092,7 +1103,7 @@ mod tests {
         cx.run_until_parked();
 
         let metadata_ids = cx.update(|cx| {
-            SidebarThreadMetadataStore::global(cx)
+            ThreadMetadataStore::global(cx)
                 .read(cx)
                 .entry_ids()
                 .collect::<Vec<_>>()
@@ -1104,7 +1115,7 @@ mod tests {
         cx.run_until_parked();
 
         let metadata_ids = cx.update(|cx| {
-            SidebarThreadMetadataStore::global(cx)
+            ThreadMetadataStore::global(cx)
                 .read(cx)
                 .entry_ids()
                 .collect::<Vec<_>>()
@@ -1119,7 +1130,7 @@ mod tests {
             cx.set_global(settings_store);
             cx.update_flags(true, vec!["agent-v2".to_string()]);
             ThreadStore::init_global(cx);
-            SidebarThreadMetadataStore::init_global(cx);
+            ThreadMetadataStore::init_global(cx);
         });
 
         let fs = FakeFs::new(cx.executor());
@@ -1177,7 +1188,7 @@ mod tests {
 
         // List all metadata from the store cache.
         let list = cx.update(|cx| {
-            let store = SidebarThreadMetadataStore::global(cx);
+            let store = ThreadMetadataStore::global(cx);
             store.read(cx).entries().collect::<Vec<_>>()
         });
 
@@ -1208,7 +1219,7 @@ mod tests {
             DbOperation::Delete(acp::SessionId::new("session-1")),
         ];
 
-        let deduped = SidebarThreadMetadataStore::dedup_db_operations(operations);
+        let deduped = ThreadMetadataStore::dedup_db_operations(operations);
 
         assert_eq!(deduped.len(), 1);
         assert_eq!(
@@ -1225,7 +1236,7 @@ mod tests {
         let old_metadata = make_metadata("session-1", "Old Title", now, PathList::default());
         let new_metadata = make_metadata("session-1", "New Title", later, PathList::default());
 
-        let deduped = SidebarThreadMetadataStore::dedup_db_operations(vec![
+        let deduped = ThreadMetadataStore::dedup_db_operations(vec![
             DbOperation::Insert(old_metadata),
             DbOperation::Insert(new_metadata.clone()),
         ]);
@@ -1240,7 +1251,7 @@ mod tests {
 
         let metadata1 = make_metadata("session-1", "First Thread", now, PathList::default());
         let metadata2 = make_metadata("session-2", "Second Thread", now, PathList::default());
-        let deduped = SidebarThreadMetadataStore::dedup_db_operations(vec![
+        let deduped = ThreadMetadataStore::dedup_db_operations(vec![
             DbOperation::Insert(metadata1.clone()),
             DbOperation::Insert(metadata2.clone()),
         ]);
@@ -1249,4 +1260,307 @@ mod tests {
         assert!(deduped.contains(&DbOperation::Insert(metadata1)));
         assert!(deduped.contains(&DbOperation::Insert(metadata2)));
     }
+
+    #[gpui::test]
+    async fn test_archive_and_unarchive_thread(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = settings::SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            cx.update_flags(true, vec!["agent-v2".to_string()]);
+            ThreadMetadataStore::init_global(cx);
+        });
+
+        let paths = PathList::new(&[Path::new("/project-a")]);
+        let now = Utc::now();
+        let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store.update(cx, |store, cx| {
+                store.save(metadata, cx);
+            });
+        });
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            let store = store.read(cx);
+
+            let path_entries = store
+                .entries_for_path(&paths)
+                .map(|e| e.session_id.0.to_string())
+                .collect::<Vec<_>>();
+            assert_eq!(path_entries, vec!["session-1"]);
+
+            let archived = store
+                .archived_entries()
+                .map(|e| e.session_id.0.to_string())
+                .collect::<Vec<_>>();
+            assert!(archived.is_empty());
+        });
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store.update(cx, |store, cx| {
+                store.archive(&acp::SessionId::new("session-1"), cx);
+            });
+        });
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            let store = store.read(cx);
+
+            let path_entries = store
+                .entries_for_path(&paths)
+                .map(|e| e.session_id.0.to_string())
+                .collect::<Vec<_>>();
+            assert!(path_entries.is_empty());
+
+            let archived = store.archived_entries().collect::<Vec<_>>();
+            assert_eq!(archived.len(), 1);
+            assert_eq!(archived[0].session_id.0.as_ref(), "session-1");
+            assert!(archived[0].archived);
+        });
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store.update(cx, |store, cx| {
+                store.unarchive(&acp::SessionId::new("session-1"), cx);
+            });
+        });
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            let store = store.read(cx);
+
+            let path_entries = store
+                .entries_for_path(&paths)
+                .map(|e| e.session_id.0.to_string())
+                .collect::<Vec<_>>();
+            assert_eq!(path_entries, vec!["session-1"]);
+
+            let archived = store
+                .archived_entries()
+                .map(|e| e.session_id.0.to_string())
+                .collect::<Vec<_>>();
+            assert!(archived.is_empty());
+        });
+    }
+
+    #[gpui::test]
+    async fn test_entries_for_path_excludes_archived(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = settings::SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            cx.update_flags(true, vec!["agent-v2".to_string()]);
+            ThreadMetadataStore::init_global(cx);
+        });
+
+        let paths = PathList::new(&[Path::new("/project-a")]);
+        let now = Utc::now();
+
+        let metadata1 = make_metadata("session-1", "Active Thread", now, paths.clone());
+        let metadata2 = make_metadata(
+            "session-2",
+            "Archived Thread",
+            now - chrono::Duration::seconds(1),
+            paths.clone(),
+        );
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store.update(cx, |store, cx| {
+                store.save(metadata1, cx);
+                store.save(metadata2, cx);
+            });
+        });
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store.update(cx, |store, cx| {
+                store.archive(&acp::SessionId::new("session-2"), cx);
+            });
+        });
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            let store = store.read(cx);
+
+            let path_entries = store
+                .entries_for_path(&paths)
+                .map(|e| e.session_id.0.to_string())
+                .collect::<Vec<_>>();
+            assert_eq!(path_entries, vec!["session-1"]);
+
+            let all_entries = store
+                .entries()
+                .map(|e| e.session_id.0.to_string())
+                .collect::<Vec<_>>();
+            assert_eq!(all_entries.len(), 2);
+            assert!(all_entries.contains(&"session-1".to_string()));
+            assert!(all_entries.contains(&"session-2".to_string()));
+
+            let archived = store
+                .archived_entries()
+                .map(|e| e.session_id.0.to_string())
+                .collect::<Vec<_>>();
+            assert_eq!(archived, vec!["session-2"]);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_save_all_persists_multiple_threads(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = settings::SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            cx.update_flags(true, vec!["agent-v2".to_string()]);
+            ThreadMetadataStore::init_global(cx);
+        });
+
+        let paths = PathList::new(&[Path::new("/project-a")]);
+        let now = Utc::now();
+
+        let m1 = make_metadata("session-1", "Thread One", now, paths.clone());
+        let m2 = make_metadata(
+            "session-2",
+            "Thread Two",
+            now - chrono::Duration::seconds(1),
+            paths.clone(),
+        );
+        let m3 = make_metadata(
+            "session-3",
+            "Thread Three",
+            now - chrono::Duration::seconds(2),
+            paths,
+        );
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store.update(cx, |store, cx| {
+                store.save_all(vec![m1, m2, m3], cx);
+            });
+        });
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            let store = store.read(cx);
+
+            let all_entries = store
+                .entries()
+                .map(|e| e.session_id.0.to_string())
+                .collect::<Vec<_>>();
+            assert_eq!(all_entries.len(), 3);
+            assert!(all_entries.contains(&"session-1".to_string()));
+            assert!(all_entries.contains(&"session-2".to_string()));
+            assert!(all_entries.contains(&"session-3".to_string()));
+
+            let entry_ids = store.entry_ids().collect::<Vec<_>>();
+            assert_eq!(entry_ids.len(), 3);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_archived_flag_persists_across_reload(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = settings::SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            cx.update_flags(true, vec!["agent-v2".to_string()]);
+            ThreadMetadataStore::init_global(cx);
+        });
+
+        let paths = PathList::new(&[Path::new("/project-a")]);
+        let now = Utc::now();
+        let metadata = make_metadata("session-1", "Thread 1", now, paths.clone());
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store.update(cx, |store, cx| {
+                store.save(metadata, cx);
+            });
+        });
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store.update(cx, |store, cx| {
+                store.archive(&acp::SessionId::new("session-1"), cx);
+            });
+        });
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store.update(cx, |store, cx| {
+                let _ = store.reload(cx);
+            });
+        });
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            let store = store.read(cx);
+
+            let thread = store
+                .entries()
+                .find(|e| e.session_id.0.as_ref() == "session-1")
+                .expect("thread should exist after reload");
+            assert!(thread.archived);
+
+            let path_entries = store
+                .entries_for_path(&paths)
+                .map(|e| e.session_id.0.to_string())
+                .collect::<Vec<_>>();
+            assert!(path_entries.is_empty());
+
+            let archived = store
+                .archived_entries()
+                .map(|e| e.session_id.0.to_string())
+                .collect::<Vec<_>>();
+            assert_eq!(archived, vec!["session-1"]);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_archive_nonexistent_thread_is_noop(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = settings::SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            cx.update_flags(true, vec!["agent-v2".to_string()]);
+            ThreadMetadataStore::init_global(cx);
+        });
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            store.update(cx, |store, cx| {
+                store.archive(&acp::SessionId::new("nonexistent"), cx);
+            });
+        });
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            let store = store.read(cx);
+
+            assert!(store.is_empty());
+            assert_eq!(store.entries().count(), 0);
+            assert_eq!(store.archived_entries().count(), 0);
+        });
+    }
 }

crates/agent_ui/src/threads_archive_view.rs 🔗

@@ -1,10 +1,8 @@
-use std::sync::Arc;
+use crate::agent_connection_store::AgentConnectionStore;
+use crate::thread_import::ThreadImportModal;
+use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
+use crate::{Agent, RemoveSelectedThread};
 
-use crate::{
-    Agent, RemoveSelectedThread, agent_connection_store::AgentConnectionStore,
-    thread_history::ThreadHistory,
-};
-use acp_thread::AgentSessionInfo;
 use agent::ThreadStore;
 use agent_client_protocol as acp;
 use agent_settings::AgentSettings;
@@ -13,19 +11,20 @@ use editor::Editor;
 use fs::Fs;
 use gpui::{
     AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render,
-    SharedString, Subscription, Task, Window, list, prelude::*, px,
+    SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px,
 };
 use itertools::Itertools as _;
 use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
-use project::{AgentId, AgentServerStore};
+use project::{AgentId, AgentRegistryStore, AgentServerStore};
 use settings::Settings as _;
 use theme::ActiveTheme;
+use ui::ThreadItem;
 use ui::{
-    ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, Divider, HighlightedLabel,
-    KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*,
-    utils::platform_title_bar_height,
+    Divider, KeyBinding, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height,
 };
-use util::ResultExt as _;
+use util::ResultExt;
+use workspace::{MultiWorkspace, Workspace};
+
 use zed_actions::agents_sidebar::FocusSidebarFilter;
 use zed_actions::editor::{MoveDown, MoveUp};
 
@@ -33,7 +32,7 @@ use zed_actions::editor::{MoveDown, MoveUp};
 enum ArchiveListItem {
     BucketSeparator(TimeBucket),
     Entry {
-        session: AgentSessionInfo,
+        thread: ThreadMetadata,
         highlight_positions: Vec<usize>,
     },
 }
@@ -95,40 +94,15 @@ fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
     }
 }
 
-fn archive_empty_state_message(
-    has_history: bool,
-    is_empty: bool,
-    has_query: bool,
-) -> Option<&'static str> {
-    if !is_empty {
-        None
-    } else if !has_history {
-        Some("This agent does not support viewing archived threads.")
-    } else if has_query {
-        Some("No threads match your search.")
-    } else {
-        Some("No archived threads yet.")
-    }
-}
-
 pub enum ThreadsArchiveViewEvent {
     Close,
-    Unarchive {
-        agent: Agent,
-        session_info: AgentSessionInfo,
-    },
+    Unarchive { thread: ThreadMetadata },
 }
 
 impl EventEmitter<ThreadsArchiveViewEvent> for ThreadsArchiveView {}
 
 pub struct ThreadsArchiveView {
-    agent_connection_store: Entity<AgentConnectionStore>,
-    agent_server_store: Entity<AgentServerStore>,
-    thread_store: Entity<ThreadStore>,
-    fs: Arc<dyn Fs>,
-    history: Option<Entity<ThreadHistory>>,
     _history_subscription: Subscription,
-    selected_agent: Agent,
     focus_handle: FocusHandle,
     list_state: ListState,
     items: Vec<ArchiveListItem>,
@@ -136,17 +110,21 @@ pub struct ThreadsArchiveView {
     hovered_index: Option<usize>,
     filter_editor: Entity<Editor>,
     _subscriptions: Vec<gpui::Subscription>,
-    selected_agent_menu: PopoverMenuHandle<ContextMenu>,
     _refresh_history_task: Task<()>,
-    is_loading: bool,
+    agent_connection_store: WeakEntity<AgentConnectionStore>,
+    agent_server_store: WeakEntity<AgentServerStore>,
+    agent_registry_store: WeakEntity<AgentRegistryStore>,
+    workspace: WeakEntity<Workspace>,
+    multi_workspace: WeakEntity<MultiWorkspace>,
 }
 
 impl ThreadsArchiveView {
     pub fn new(
-        agent_connection_store: Entity<AgentConnectionStore>,
-        agent_server_store: Entity<AgentServerStore>,
-        thread_store: Entity<ThreadStore>,
-        fs: Arc<dyn Fs>,
+        agent_connection_store: WeakEntity<AgentConnectionStore>,
+        agent_server_store: WeakEntity<AgentServerStore>,
+        agent_registry_store: WeakEntity<AgentRegistryStore>,
+        workspace: WeakEntity<Workspace>,
+        multi_workspace: WeakEntity<MultiWorkspace>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -178,6 +156,13 @@ impl ThreadsArchiveView {
         )
         .detach();
 
+        let thread_metadata_store_subscription = cx.observe(
+            &ThreadMetadataStore::global(cx),
+            |this: &mut Self, _, cx| {
+                this.update_items(cx);
+            },
+        );
+
         cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, _window, cx| {
             this.selection = None;
             cx.notify();
@@ -185,25 +170,26 @@ impl ThreadsArchiveView {
         .detach();
 
         let mut this = Self {
-            agent_connection_store,
-            agent_server_store,
-            thread_store,
-            fs,
-            history: None,
             _history_subscription: Subscription::new(|| {}),
-            selected_agent: Agent::NativeAgent,
             focus_handle,
             list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
             items: Vec::new(),
             selection: None,
             hovered_index: None,
             filter_editor,
-            _subscriptions: vec![filter_editor_subscription],
-            selected_agent_menu: PopoverMenuHandle::default(),
+            _subscriptions: vec![
+                filter_editor_subscription,
+                thread_metadata_store_subscription,
+            ],
             _refresh_history_task: Task::ready(()),
-            is_loading: true,
+            agent_registry_store,
+            agent_connection_store,
+            agent_server_store,
+            workspace,
+            multi_workspace,
         };
-        this.set_selected_agent(Agent::NativeAgent, window, cx);
+
+        this.update_items(cx);
         this
     }
 
@@ -220,59 +206,14 @@ impl ThreadsArchiveView {
         handle.focus(window, cx);
     }
 
-    fn set_selected_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
-        self.selected_agent = agent.clone();
-        self.is_loading = true;
-        self.reset_history_subscription();
-        self.history = None;
-        self.items.clear();
-        self.selection = None;
-        self.list_state.reset(0);
-        self.reset_filter_editor_text(window, cx);
-
-        let server = agent.server(self.fs.clone(), self.thread_store.clone());
-        let connection = self
-            .agent_connection_store
-            .update(cx, |store, cx| store.request_connection(agent, server, cx));
-
-        let task = connection.read(cx).wait_for_connection();
-        self._refresh_history_task = cx.spawn(async move |this, cx| {
-            if let Some(state) = task.await.log_err() {
-                this.update(cx, |this, cx| this.set_history(state.history, cx))
-                    .ok();
-            }
-        });
-
-        cx.notify();
-    }
-
-    fn reset_history_subscription(&mut self) {
-        self._history_subscription = Subscription::new(|| {});
-    }
-
-    fn set_history(&mut self, history: Option<Entity<ThreadHistory>>, cx: &mut Context<Self>) {
-        self.reset_history_subscription();
-
-        if let Some(history) = &history {
-            self._history_subscription = cx.observe(history, |this, _, cx| {
-                this.update_items(cx);
-            });
-            history.update(cx, |history, cx| {
-                history.refresh_full_history(cx);
-            });
-        }
-        self.history = history;
-        self.is_loading = false;
-        self.update_items(cx);
-        cx.notify();
-    }
-
     fn update_items(&mut self, cx: &mut Context<Self>) {
-        let sessions = self
-            .history
-            .as_ref()
-            .map(|h| h.read(cx).sessions().to_vec())
-            .unwrap_or_default();
+        let sessions = ThreadMetadataStore::global(cx)
+            .read(cx)
+            .archived_entries()
+            .sorted_by_cached_key(|t| t.created_at.unwrap_or(t.updated_at))
+            .rev()
+            .collect::<Vec<_>>();
+
         let query = self.filter_editor.read(cx).text(cx).to_lowercase();
         let today = Local::now().naive_local().date();
 
@@ -281,8 +222,7 @@ impl ThreadsArchiveView {
 
         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) {
+                match fuzzy_match_positions(&query, &session.title) {
                     Some(positions) => positions,
                     None => continue,
                 }
@@ -290,13 +230,15 @@ impl ThreadsArchiveView {
                 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);
+            let entry_bucket = {
+                let entry_date = session
+                    .created_at
+                    .unwrap_or(session.updated_at)
+                    .with_timezone(&Local)
+                    .naive_local()
+                    .date();
+                TimeBucket::from_dates(today, entry_date)
+            };
 
             if Some(entry_bucket) != current_bucket {
                 current_bucket = Some(entry_bucket);
@@ -304,7 +246,7 @@ impl ThreadsArchiveView {
             }
 
             items.push(ArchiveListItem::Entry {
-                session,
+                thread: session,
                 highlight_positions,
             });
         }
@@ -324,47 +266,13 @@ impl ThreadsArchiveView {
 
     fn unarchive_thread(
         &mut self,
-        session_info: AgentSessionInfo,
+        thread: ThreadMetadata,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         self.selection = None;
         self.reset_filter_editor_text(window, cx);
-        cx.emit(ThreadsArchiveViewEvent::Unarchive {
-            agent: self.selected_agent.clone(),
-            session_info,
-        });
-    }
-
-    fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
-        let Some(history) = &self.history else {
-            return;
-        };
-        if !history.read(cx).supports_delete() {
-            return;
-        }
-        let session_id = session_id.clone();
-        history.update(cx, |history, cx| {
-            history
-                .delete_session(&session_id, cx)
-                .detach_and_log_err(cx);
-        });
-    }
-
-    fn remove_selected_thread(
-        &mut self,
-        _: &RemoveSelectedThread,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let Some(ix) = self.selection else {
-            return;
-        };
-        let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
-            return;
-        };
-        let session_id = session.session_id.clone();
-        self.delete_thread(&session_id, cx);
+        cx.emit(ThreadsArchiveViewEvent::Unarchive { thread });
     }
 
     fn is_selectable_item(&self, ix: usize) -> bool {
@@ -450,16 +358,15 @@ impl ThreadsArchiveView {
 
     fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
         let Some(ix) = self.selection else { return };
-        let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
+        let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
             return;
         };
 
-        let can_unarchive = session.work_dirs.as_ref().is_some_and(|p| !p.is_empty());
-        if !can_unarchive {
+        if thread.folder_paths.is_empty() {
             return;
         }
 
-        self.unarchive_thread(session.clone(), window, cx);
+        self.unarchive_thread(thread.clone(), window, cx);
     }
 
     fn render_list_entry(
@@ -485,71 +392,40 @@ impl ThreadsArchiveView {
                 )
                 .into_any_element(),
             ArchiveListItem::Entry {
-                session,
+                thread,
                 highlight_positions,
             } => {
                 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 can_unarchive = session.work_dirs.as_ref().is_some_and(|p| !p.is_empty());
-
-                let supports_delete = self
-                    .history
-                    .as_ref()
-                    .map(|h| h.read(cx).supports_delete())
-                    .unwrap_or(false);
+                let is_hovered = self.hovered_index == Some(ix);
 
-                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 timestamp = session
-                    .created_at
-                    .or(session.updated_at)
-                    .map(format_history_entry_timestamp);
+                let timestamp =
+                    format_history_entry_timestamp(thread.created_at.unwrap_or(thread.updated_at));
 
-                let highlight_positions = highlight_positions.clone();
-                let title_label = if highlight_positions.is_empty() {
-                    Label::new(title).truncate().flex_1().into_any_element()
+                let icon_from_external_svg = self
+                    .agent_server_store
+                    .upgrade()
+                    .and_then(|store| store.read(cx).agent_icon(&thread.agent_id));
+
+                let icon = if thread.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
+                    IconName::ZedAgent
                 } else {
-                    HighlightedLabel::new(title, highlight_positions)
-                        .truncate()
-                        .flex_1()
-                        .into_any_element()
+                    IconName::Sparkle
                 };
 
-                h_flex()
-                    .id(id)
-                    .min_w_0()
-                    .w_full()
-                    .px(DynamicSpacing::Base06.rems(cx))
-                    .border_1()
-                    .map(|this| {
-                        if is_focused {
-                            this.border_color(cx.theme().colors().border_focused)
-                        } else {
-                            this.border_color(gpui::transparent_black())
-                        }
+                ThreadItem::new(id, thread.title.clone())
+                    .icon(icon)
+                    .when_some(icon_from_external_svg, |this, svg| {
+                        this.custom_icon_from_external_svg(svg)
                     })
+                    .timestamp(timestamp)
+                    .highlight_positions(highlight_positions.clone())
+                    .project_paths(thread.folder_paths.paths_owned())
+                    .focused(is_focused)
+                    .hovered(is_hovered)
                     .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
                         if *is_hovered {
                             this.hovered_index = Some(ix);
@@ -558,105 +434,59 @@ impl ThreadsArchiveView {
                         }
                         cx.notify();
                     }))
-                    .child(
-                        v_flex()
-                            .min_w_0()
-                            .w_full()
-                            .p_1()
-                            .child(
-                                h_flex()
-                                    .min_w_0()
-                                    .w_full()
-                                    .gap_1()
-                                    .justify_between()
-                                    .child(title_label)
-                                    .when(hovered || is_focused, |this| {
-                                        this.child(
-                                            h_flex()
-                                                .gap_0p5()
-                                                .when(can_unarchive, |this| {
-                                                    this.child(
-                                                        Button::new("unarchive-thread", "Restore")
-                                                            .style(ButtonStyle::Filled)
-                                                            .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,
-                                                        )
-                                                        .style(ButtonStyle::Filled)
-                                                        .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();
-                                                            },
-                                                        )),
-                                                    )
-                                                }),
-                                        )
-                                    }),
-                            )
+                    .action_slot(
+                        h_flex()
+                            .gap_2()
+                            .when(is_hovered || is_focused, |this| {
+                                let focus_handle = self.focus_handle.clone();
+                                this.child(
+                                    Button::new("unarchive-thread", "Open")
+                                        .style(ButtonStyle::Filled)
+                                        .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({
+                                            let thread = thread.clone();
+                                            cx.listener(move |this, _, window, cx| {
+                                                this.unarchive_thread(thread.clone(), window, cx);
+                                            })
+                                        }),
+                                )
+                            })
                             .child(
-                                h_flex()
-                                    .gap_1()
-                                    .when_some(timestamp, |this, ts| {
-                                        this.child(
-                                            Label::new(ts)
-                                                .size(LabelSize::Small)
-                                                .color(Color::Muted),
-                                        )
+                                IconButton::new("delete-thread", IconName::Trash)
+                                    .style(ButtonStyle::Filled)
+                                    .icon_size(IconSize::Small)
+                                    .icon_color(Color::Muted)
+                                    .tooltip({
+                                        move |_window, cx| {
+                                            Tooltip::for_action_in(
+                                                "Delete Thread",
+                                                &RemoveSelectedThread,
+                                                &focus_handle,
+                                                cx,
+                                            )
+                                        }
                                     })
-                                    .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),
-                                        )
+                                    .on_click({
+                                        let agent = thread.agent_id.clone();
+                                        let session_id = thread.session_id.clone();
+                                        cx.listener(move |this, _, _, cx| {
+                                            this.delete_thread(
+                                                session_id.clone(),
+                                                agent.clone(),
+                                                cx,
+                                            );
+                                            cx.stop_propagation();
+                                        })
                                     }),
                             ),
                     )
@@ -665,134 +495,67 @@ impl ThreadsArchiveView {
         }
     }
 
-    fn render_agent_picker(&self, cx: &mut Context<Self>) -> PopoverMenu<ContextMenu> {
-        let agent_server_store = self.agent_server_store.clone();
+    fn delete_thread(
+        &mut self,
+        session_id: acp::SessionId,
+        agent: AgentId,
+        cx: &mut Context<Self>,
+    ) {
+        ThreadMetadataStore::global(cx)
+            .update(cx, |store, cx| store.delete(session_id.clone(), cx));
 
-        let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() {
-            (IconName::ChevronUp, Color::Accent)
-        } else {
-            (IconName::ChevronDown, Color::Muted)
+        let agent = Agent::from(agent);
+
+        let Some(agent_connection_store) = self.agent_connection_store.upgrade() else {
+            return;
         };
+        let fs = <dyn Fs>::global(cx);
 
-        let selected_agent_icon = if let Agent::Custom { id } = &self.selected_agent {
-            let store = agent_server_store.read(cx);
-            let icon = store.agent_icon(&id);
+        let task = agent_connection_store.update(cx, |store, cx| {
+            store
+                .request_connection(agent.clone(), agent.server(fs, ThreadStore::global(cx)), cx)
+                .read(cx)
+                .wait_for_connection()
+        });
+        cx.spawn(async move |_this, cx| {
+            let state = task.await?;
+            let task = cx.update(|cx| {
+                if let Some(list) = state.connection.session_list(cx) {
+                    list.delete_session(&session_id, cx)
+                } else {
+                    Task::ready(Ok(()))
+                }
+            });
+            task.await
+        })
+        .detach_and_log_err(cx);
+    }
 
-            if let Some(icon) = icon {
-                Icon::from_external_svg(icon)
-            } else {
-                Icon::new(IconName::Sparkle)
-            }
-            .color(Color::Muted)
-            .size(IconSize::Small)
-        } else {
-            Icon::new(IconName::ZedAgent)
-                .color(Color::Muted)
-                .size(IconSize::Small)
+    fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(agent_server_store) = self.agent_server_store.upgrade() else {
+            return;
+        };
+        let Some(agent_registry_store) = self.agent_registry_store.upgrade() else {
+            return;
         };
 
-        let this = cx.weak_entity();
-
-        PopoverMenu::new("agent_history_menu")
-            .trigger(
-                ButtonLike::new("selected_agent")
-                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-                    .child(
-                        h_flex().gap_1().child(selected_agent_icon).child(
-                            Icon::new(chevron_icon)
-                                .color(icon_color)
-                                .size(IconSize::XSmall),
-                        ),
-                    ),
-            )
-            .menu(move |window, cx| {
-                Some(ContextMenu::build(window, cx, |menu, _window, cx| {
-                    menu.item(
-                        ContextMenuEntry::new("Zed Agent")
-                            .icon(IconName::ZedAgent)
-                            .icon_color(Color::Muted)
-                            .handler({
-                                let this = this.clone();
-                                move |window, cx| {
-                                    this.update(cx, |this, cx| {
-                                        this.set_selected_agent(Agent::NativeAgent, window, cx)
-                                    })
-                                    .ok();
-                                }
-                            }),
+        let workspace_handle = self.workspace.clone();
+        let multi_workspace = self.multi_workspace.clone();
+
+        self.workspace
+            .update(cx, |workspace, cx| {
+                workspace.toggle_modal(window, cx, |window, cx| {
+                    ThreadImportModal::new(
+                        agent_server_store,
+                        agent_registry_store,
+                        workspace_handle.clone(),
+                        multi_workspace.clone(),
+                        window,
+                        cx,
                     )
-                    .separator()
-                    .map(|mut menu| {
-                        let agent_server_store = agent_server_store.read(cx);
-                        let registry_store = project::AgentRegistryStore::try_global(cx);
-                        let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
-
-                        struct AgentMenuItem {
-                            id: AgentId,
-                            display_name: SharedString,
-                        }
-
-                        let agent_items = agent_server_store
-                            .external_agents()
-                            .map(|agent_id| {
-                                let display_name = agent_server_store
-                                    .agent_display_name(agent_id)
-                                    .or_else(|| {
-                                        registry_store_ref
-                                            .as_ref()
-                                            .and_then(|store| store.agent(agent_id))
-                                            .map(|a| a.name().clone())
-                                    })
-                                    .unwrap_or_else(|| agent_id.0.clone());
-                                AgentMenuItem {
-                                    id: agent_id.clone(),
-                                    display_name,
-                                }
-                            })
-                            .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
-                            .collect::<Vec<_>>();
-
-                        for item in &agent_items {
-                            let mut entry = ContextMenuEntry::new(item.display_name.clone());
-
-                            let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| {
-                                registry_store_ref
-                                    .as_ref()
-                                    .and_then(|store| store.agent(&item.id))
-                                    .and_then(|a| a.icon_path().cloned())
-                            });
-
-                            if let Some(icon_path) = icon_path {
-                                entry = entry.custom_icon_svg(icon_path);
-                            } else {
-                                entry = entry.icon(IconName::ZedAgent);
-                            }
-
-                            entry = entry.icon_color(Color::Muted).handler({
-                                let this = this.clone();
-                                let agent = Agent::Custom {
-                                    id: item.id.clone(),
-                                };
-                                move |window, cx| {
-                                    this.update(cx, |this, cx| {
-                                        this.set_selected_agent(agent.clone(), window, cx)
-                                    })
-                                    .ok();
-                                }
-                            });
-
-                            menu = menu.item(entry);
-                        }
-                        menu
-                    })
-                }))
-            })
-            .with_handle(self.selected_agent_menu.clone())
-            .anchor(gpui::Corner::TopRight)
-            .offset(gpui::Point {
-                x: px(1.0),
-                y: px(1.0),
+                });
             })
+            .log_err();
     }
 
     fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
@@ -842,19 +605,27 @@ impl ThreadsArchiveView {
             .when(show_focus_keybinding, |this| {
                 this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
             })
-            .when(!has_query && !show_focus_keybinding, |this| {
-                this.child(self.render_agent_picker(cx))
-            })
-            .when(has_query, |this| {
-                this.child(
-                    IconButton::new("clear_filter", IconName::Close)
-                        .icon_size(IconSize::Small)
-                        .tooltip(Tooltip::text("Clear Search"))
-                        .on_click(cx.listener(|this, _, window, cx| {
-                            this.reset_filter_editor_text(window, cx);
-                            this.update_items(cx);
-                        })),
-                )
+            .map(|this| {
+                if has_query {
+                    this.child(
+                        IconButton::new("clear-filter", IconName::Close)
+                            .icon_size(IconSize::Small)
+                            .tooltip(Tooltip::text("Clear Search"))
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.reset_filter_editor_text(window, cx);
+                                this.update_items(cx);
+                            })),
+                    )
+                } else {
+                    this.child(
+                        IconButton::new("import-thread", IconName::Plus)
+                            .icon_size(IconSize::Small)
+                            .tooltip(Tooltip::text("Import ACP Threads"))
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.show_thread_import_modal(window, cx);
+                            })),
+                    )
+                }
             })
     }
 }
@@ -888,30 +659,18 @@ impl Focusable for ThreadsArchiveView {
     }
 }
 
-impl ThreadsArchiveView {
-    fn empty_state_message(&self, is_empty: bool, has_query: bool) -> Option<&'static str> {
-        archive_empty_state_message(self.history.is_some(), is_empty, has_query)
-    }
-}
-
 impl Render for ThreadsArchiveView {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let is_empty = self.items.is_empty();
         let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
 
-        let content = if self.is_loading {
-            v_flex()
-                .flex_1()
-                .justify_center()
-                .items_center()
-                .child(
-                    Icon::new(IconName::LoadCircle)
-                        .size(IconSize::Small)
-                        .color(Color::Muted)
-                        .with_rotate_animation(2),
-                )
-                .into_any_element()
-        } else if let Some(message) = self.empty_state_message(is_empty, has_query) {
+        let content = if is_empty {
+            let message = if has_query {
+                "No threads match your search."
+            } else {
+                "No archived or hidden threads yet."
+            };
+
             v_flex()
                 .flex_1()
                 .justify_center()
@@ -948,44 +707,8 @@ impl Render for ThreadsArchiveView {
             .on_action(cx.listener(Self::select_first))
             .on_action(cx.listener(Self::select_last))
             .on_action(cx.listener(Self::confirm))
-            .on_action(cx.listener(Self::remove_selected_thread))
             .size_full()
             .child(self.render_header(window, cx))
             .child(content)
     }
 }
-
-#[cfg(test)]
-mod tests {
-    use super::archive_empty_state_message;
-
-    #[test]
-    fn empty_state_message_returns_none_when_archive_has_items() {
-        assert_eq!(archive_empty_state_message(false, false, false), None);
-        assert_eq!(archive_empty_state_message(true, false, true), None);
-    }
-
-    #[test]
-    fn empty_state_message_distinguishes_unsupported_history() {
-        assert_eq!(
-            archive_empty_state_message(false, true, false),
-            Some("This agent does not support viewing archived threads.")
-        );
-        assert_eq!(
-            archive_empty_state_message(false, true, true),
-            Some("This agent does not support viewing archived threads.")
-        );
-    }
-
-    #[test]
-    fn empty_state_message_distinguishes_empty_history_and_search_results() {
-        assert_eq!(
-            archive_empty_state_message(true, true, false),
-            Some("No archived threads yet.")
-        );
-        assert_eq!(
-            archive_empty_state_message(true, true, true),
-            Some("No threads match your search.")
-        );
-    }
-}

crates/project/src/agent_server_store.rs 🔗

@@ -307,9 +307,9 @@ impl AgentServerStore {
         cx.emit(AgentServersUpdated);
     }
 
-    pub fn agent_icon(&self, name: &AgentId) -> Option<SharedString> {
+    pub fn agent_icon(&self, id: &AgentId) -> Option<SharedString> {
         self.external_agents
-            .get(name)
+            .get(id)
             .and_then(|entry| entry.icon.clone())
     }
 

crates/sidebar/src/sidebar.rs 🔗

@@ -4,7 +4,7 @@ use acp_thread::ThreadStatus;
 use action_log::DiffStats;
 use agent_client_protocol::{self as acp};
 use agent_settings::AgentSettings;
-use agent_ui::thread_metadata_store::{SidebarThreadMetadataStore, ThreadMetadata};
+use agent_ui::thread_metadata_store::ThreadMetadataStore;
 use agent_ui::threads_archive_view::{
     ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
 };
@@ -21,7 +21,7 @@ use gpui::{
 use menu::{
     Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
 };
-use project::{Event as ProjectEvent, linked_worktree_short_name};
+use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, linked_worktree_short_name};
 use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
 use ui::utils::platform_title_bar_height;
 
@@ -383,12 +383,9 @@ impl Sidebar {
         })
         .detach();
 
-        cx.observe(
-            &SidebarThreadMetadataStore::global(cx),
-            |this, _store, cx| {
-                this.update_entries(cx);
-            },
-        )
+        cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| {
+            this.update_entries(cx);
+        })
         .detach();
 
         cx.observe_flag::<AgentV2FeatureFlag, _>(window, |_is_enabled, this, _window, cx| {
@@ -712,20 +709,16 @@ impl Sidebar {
             .iter()
             .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
 
-        let resolve_agent = |row: &ThreadMetadata| -> (Agent, IconName, Option<SharedString>) {
-            match &row.agent_id {
-                None => (Agent::NativeAgent, IconName::ZedAgent, None),
-                Some(id) => {
-                    let custom_icon = agent_server_store
-                        .as_ref()
-                        .and_then(|store| store.read(cx).agent_icon(id));
-                    (
-                        Agent::Custom { id: id.clone() },
-                        IconName::Terminal,
-                        custom_icon,
-                    )
-                }
-            }
+        let resolve_agent = |agent_id: &AgentId| -> (Agent, IconName, Option<SharedString>) {
+            let agent = Agent::from(agent_id.clone());
+            let icon = match agent {
+                Agent::NativeAgent => IconName::ZedAgent,
+                Agent::Custom { .. } => IconName::Terminal,
+            };
+            let icon_from_external_svg = agent_server_store
+                .as_ref()
+                .and_then(|store| store.read(cx).agent_icon(&agent_id));
+            (agent, icon, icon_from_external_svg)
         };
 
         for (group_name, group) in project_groups.groups() {
@@ -764,7 +757,7 @@ impl Sidebar {
 
             if should_load_threads {
                 let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
-                let thread_store = SidebarThreadMetadataStore::global(cx);
+                let thread_store = ThreadMetadataStore::global(cx);
 
                 // Load threads from each workspace in the group.
                 for workspace in &group.workspaces {
@@ -774,7 +767,7 @@ impl Sidebar {
                         if !seen_session_ids.insert(row.session_id.clone()) {
                             continue;
                         }
-                        let (agent, icon, icon_from_external_svg) = resolve_agent(&row);
+                        let (agent, icon, icon_from_external_svg) = resolve_agent(&row.agent_id);
                         let worktrees =
                             worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
                         threads.push(ThreadEntry {
@@ -824,7 +817,7 @@ impl Sidebar {
                         if !seen_session_ids.insert(row.session_id.clone()) {
                             continue;
                         }
-                        let (agent, icon, icon_from_external_svg) = resolve_agent(&row);
+                        let (agent, icon, icon_from_external_svg) = resolve_agent(&row.agent_id);
                         let worktrees =
                             worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
                         threads.push(ThreadEntry {
@@ -2092,12 +2085,8 @@ impl Sidebar {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        // Eagerly save thread metadata so that the sidebar is updated immediately
-        SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
-            store.save(
-                ThreadMetadata::from_session_info(agent.id(), &session_info),
-                cx,
-            )
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+            store.unarchive(&session_info.session_id, cx)
         });
 
         if let Some(path_list) = &session_info.work_dirs {
@@ -2276,6 +2265,8 @@ impl Sidebar {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        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
@@ -2376,9 +2367,6 @@ impl Sidebar {
                 }
             }
         }
-
-        SidebarThreadMetadataStore::global(cx)
-            .update(cx, |store, cx| store.delete(session_id.clone(), cx));
     }
 
     fn remove_selected_thread(
@@ -2393,11 +2381,13 @@ impl Sidebar {
         let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
             return;
         };
-        if thread.agent != Agent::NativeAgent {
-            return;
+        match thread.status {
+            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => return,
+            AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
         }
+
         let session_id = thread.session_info.session_id.clone();
-        self.archive_thread(&session_id, window, cx);
+        self.archive_thread(&session_id, window, cx)
     }
 
     fn record_thread_access(&mut self, session_id: &acp::SessionId) {
@@ -3273,31 +3263,34 @@ impl Sidebar {
         }) else {
             return;
         };
-
         let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
             return;
         };
+        let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
+            return;
+        };
 
-        let thread_store = agent_panel.read(cx).thread_store().clone();
-        let fs = active_workspace.read(cx).project().read(cx).fs().clone();
-        let agent_connection_store = agent_panel.read(cx).connection_store().clone();
         let agent_server_store = active_workspace
             .read(cx)
             .project()
             .read(cx)
             .agent_server_store()
-            .clone();
+            .downgrade();
+
+        let agent_connection_store = agent_panel.read(cx).connection_store().downgrade();
 
         let archive_view = cx.new(|cx| {
             ThreadsArchiveView::new(
-                agent_connection_store,
-                agent_server_store,
-                thread_store,
-                fs,
+                agent_connection_store.clone(),
+                agent_server_store.clone(),
+                agent_registry_store.downgrade(),
+                active_workspace.downgrade(),
+                self.multi_workspace.clone(),
                 window,
                 cx,
             )
         });
+
         let subscription = cx.subscribe_in(
             &archive_view,
             window,
@@ -3305,12 +3298,20 @@ impl Sidebar {
                 ThreadsArchiveViewEvent::Close => {
                     this.show_thread_list(window, cx);
                 }
-                ThreadsArchiveViewEvent::Unarchive {
-                    agent,
-                    session_info,
-                } => {
+                ThreadsArchiveViewEvent::Unarchive { thread } => {
                     this.show_thread_list(window, cx);
-                    this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx);
+
+                    let agent = Agent::from(thread.agent_id.clone());
+                    let session_info = acp_thread::AgentSessionInfo {
+                        session_id: thread.session_id.clone(),
+                        work_dirs: Some(thread.folder_paths.clone()),
+                        title: Some(thread.title.clone()),
+                        updated_at: Some(thread.updated_at),
+                        created_at: thread.created_at,
+                        meta: None,
+                    };
+
+                    this.activate_archived_thread(agent, session_info, window, cx);
                 }
             },
         );

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -1,7 +1,10 @@
 use super::*;
 use acp_thread::StubAgentConnection;
 use agent::ThreadStore;
-use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message};
+use agent_ui::{
+    test_support::{active_session_id, open_thread_with_connection, send_message},
+    thread_metadata_store::ThreadMetadata,
+};
 use assistant_text_thread::TextThreadStore;
 use chrono::DateTime;
 use feature_flags::FeatureFlagAppExt as _;
@@ -20,7 +23,7 @@ fn init_test(cx: &mut TestAppContext) {
         editor::init(cx);
         cx.update_flags(false, vec!["agent-v2".into()]);
         ThreadStore::init_global(cx);
-        SidebarThreadMetadataStore::init_global(cx);
+        ThreadMetadataStore::init_global(cx);
         language_model::LanguageModelRegistry::test(cx);
         prompt_store::init(cx);
     });
@@ -113,14 +116,15 @@ async fn save_thread_metadata(
 ) {
     let metadata = ThreadMetadata {
         session_id,
-        agent_id: None,
+        agent_id: agent::ZED_AGENT_ID.clone(),
         title,
         updated_at,
         created_at: None,
         folder_paths: path_list,
+        archived: false,
     };
     cx.update(|cx| {
-        SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx))
     });
     cx.run_until_parked();
 }
@@ -1076,7 +1080,7 @@ async fn init_test_project_with_agent_panel(
     cx.update(|cx| {
         cx.update_flags(false, vec!["agent-v2".into()]);
         ThreadStore::init_global(cx);
-        SidebarThreadMetadataStore::init_global(cx);
+        ThreadMetadataStore::init_global(cx);
         language_model::LanguageModelRegistry::test(cx);
         prompt_store::init(cx);
     });
@@ -2249,7 +2253,7 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
     cx.update(|cx| {
         cx.update_flags(false, vec!["agent-v2".into()]);
         ThreadStore::init_global(cx);
-        SidebarThreadMetadataStore::init_global(cx);
+        ThreadMetadataStore::init_global(cx);
         language_model::LanguageModelRegistry::test(cx);
         prompt_store::init(cx);
     });
@@ -2816,7 +2820,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
     cx.update(|cx| {
         cx.update_flags(false, vec!["agent-v2".into()]);
         ThreadStore::init_global(cx);
-        SidebarThreadMetadataStore::init_global(cx);
+        ThreadMetadataStore::init_global(cx);
         language_model::LanguageModelRegistry::test(cx);
         prompt_store::init(cx);
     });
@@ -2928,7 +2932,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp
     cx.update(|cx| {
         cx.update_flags(false, vec!["agent-v2".into()]);
         ThreadStore::init_global(cx);
-        SidebarThreadMetadataStore::init_global(cx);
+        ThreadMetadataStore::init_global(cx);
         language_model::LanguageModelRegistry::test(cx);
         prompt_store::init(cx);
     });
@@ -3874,7 +3878,7 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon
     cx.update(|cx| {
         cx.update_flags(false, vec!["agent-v2".into()]);
         ThreadStore::init_global(cx);
-        SidebarThreadMetadataStore::init_global(cx);
+        ThreadMetadataStore::init_global(cx);
         language_model::LanguageModelRegistry::test(cx);
         prompt_store::init(cx);
     });
@@ -4178,11 +4182,11 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
     send_message(&panel, cx);
     let session_id_c = active_session_id(&panel, cx);
     cx.update(|_, cx| {
-        SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
             store.save(
                 ThreadMetadata {
                     session_id: session_id_c.clone(),
-                    agent_id: None,
+                    agent_id: agent::ZED_AGENT_ID.clone(),
                     title: "Thread C".into(),
                     updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0)
                         .unwrap(),
@@ -4190,6 +4194,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
                         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
                     ),
                     folder_paths: path_list.clone(),
+                    archived: false,
                 },
                 cx,
             )
@@ -4205,11 +4210,11 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
     send_message(&panel, cx);
     let session_id_b = active_session_id(&panel, cx);
     cx.update(|_, cx| {
-        SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
             store.save(
                 ThreadMetadata {
                     session_id: session_id_b.clone(),
-                    agent_id: None,
+                    agent_id: agent::ZED_AGENT_ID.clone(),
                     title: "Thread B".into(),
                     updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0)
                         .unwrap(),
@@ -4217,6 +4222,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
                         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
                     ),
                     folder_paths: path_list.clone(),
+                    archived: false,
                 },
                 cx,
             )
@@ -4232,11 +4238,11 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
     send_message(&panel, cx);
     let session_id_a = active_session_id(&panel, cx);
     cx.update(|_, cx| {
-        SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
             store.save(
                 ThreadMetadata {
                     session_id: session_id_a.clone(),
-                    agent_id: None,
+                    agent_id: agent::ZED_AGENT_ID.clone(),
                     title: "Thread A".into(),
                     updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0)
                         .unwrap(),
@@ -4244,6 +4250,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
                         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
                     ),
                     folder_paths: path_list.clone(),
+                    archived: false,
                 },
                 cx,
             )
@@ -4343,11 +4350,11 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
     // ── 3. Add a historical thread (no last_accessed_at, no message sent) ──
     // This thread was never opened in a panel — it only exists in metadata.
     cx.update(|_, cx| {
-        SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
             store.save(
                 ThreadMetadata {
                     session_id: acp::SessionId::new(Arc::from("thread-historical")),
-                    agent_id: None,
+                    agent_id: agent::ZED_AGENT_ID.clone(),
                     title: "Historical Thread".into(),
                     updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0)
                         .unwrap(),
@@ -4355,6 +4362,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
                         chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
                     ),
                     folder_paths: path_list.clone(),
+                    archived: false,
                 },
                 cx,
             )
@@ -4394,11 +4402,11 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
 
     // ── 4. Add another historical thread with older created_at ─────────
     cx.update(|_, cx| {
-        SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
             store.save(
                 ThreadMetadata {
                     session_id: acp::SessionId::new(Arc::from("thread-old-historical")),
-                    agent_id: None,
+                    agent_id: agent::ZED_AGENT_ID.clone(),
                     title: "Old Historical Thread".into(),
                     updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0)
                         .unwrap(),
@@ -4406,6 +4414,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
                         chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(),
                     ),
                     folder_paths: path_list.clone(),
+                    archived: false,
                 },
                 cx,
             )
@@ -4439,6 +4448,119 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) {
     cx.run_until_parked();
 }
 
+#[gpui::test]
+async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut TestAppContext) {
+    let project = init_test_project("/my-project", cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+    let sidebar = setup_sidebar(&multi_workspace, cx);
+
+    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+
+    save_thread_metadata(
+        acp::SessionId::new(Arc::from("thread-to-archive")),
+        "Thread To Archive".into(),
+        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+        path_list.clone(),
+        cx,
+    )
+    .await;
+    cx.run_until_parked();
+
+    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+    cx.run_until_parked();
+
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    assert!(
+        entries.iter().any(|e| e.contains("Thread To Archive")),
+        "expected thread to be visible before archiving, got: {entries:?}"
+    );
+
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        sidebar.archive_thread(
+            &acp::SessionId::new(Arc::from("thread-to-archive")),
+            window,
+            cx,
+        );
+    });
+    cx.run_until_parked();
+
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    assert!(
+        !entries.iter().any(|e| e.contains("Thread To Archive")),
+        "expected thread to be hidden after archiving, got: {entries:?}"
+    );
+
+    cx.update(|_, cx| {
+        let store = ThreadMetadataStore::global(cx);
+        let archived: Vec<_> = store.read(cx).archived_entries().collect();
+        assert_eq!(archived.len(), 1);
+        assert_eq!(archived[0].session_id.0.as_ref(), "thread-to-archive");
+        assert!(archived[0].archived);
+    });
+}
+
+#[gpui::test]
+async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
+    let project = init_test_project("/my-project", cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+    let sidebar = setup_sidebar(&multi_workspace, cx);
+
+    let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+
+    save_thread_metadata(
+        acp::SessionId::new(Arc::from("visible-thread")),
+        "Visible Thread".into(),
+        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
+        path_list.clone(),
+        cx,
+    )
+    .await;
+
+    cx.update(|_, cx| {
+        let metadata = ThreadMetadata {
+            session_id: acp::SessionId::new(Arc::from("archived-thread")),
+            agent_id: agent::ZED_AGENT_ID.clone(),
+            title: "Archived Thread".into(),
+            updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+            created_at: None,
+            folder_paths: path_list.clone(),
+            archived: true,
+        };
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
+    });
+    cx.run_until_parked();
+
+    multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+    cx.run_until_parked();
+
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    assert!(
+        entries.iter().any(|e| e.contains("Visible Thread")),
+        "expected visible thread in sidebar, got: {entries:?}"
+    );
+    assert!(
+        !entries.iter().any(|e| e.contains("Archived Thread")),
+        "expected archived thread to be hidden from sidebar, got: {entries:?}"
+    );
+
+    cx.update(|_, cx| {
+        let store = ThreadMetadataStore::global(cx);
+        let all: Vec<_> = store.read(cx).entries().collect();
+        assert_eq!(
+            all.len(),
+            2,
+            "expected 2 total entries in the store, got: {}",
+            all.len()
+        );
+
+        let archived: Vec<_> = store.read(cx).archived_entries().collect();
+        assert_eq!(archived.len(), 1);
+        assert_eq!(archived[0].session_id.0.as_ref(), "archived-thread");
+    });
+}
+
 mod property_test {
     use super::*;
     use gpui::EntityId;
@@ -4581,14 +4703,15 @@ mod property_test {
             + chrono::Duration::seconds(state.thread_counter as i64);
         let metadata = ThreadMetadata {
             session_id,
-            agent_id: None,
+            agent_id: agent::ZED_AGENT_ID.clone(),
             title,
             updated_at,
             created_at: None,
             folder_paths: path_list,
+            archived: false,
         };
         cx.update(|_, cx| {
-            SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
+            ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
         });
     }
 
@@ -4615,7 +4738,7 @@ mod property_test {
             Operation::DeleteThread { index } => {
                 let session_id = state.remove_thread(index);
                 cx.update(|_, cx| {
-                    SidebarThreadMetadataStore::global(cx)
+                    ThreadMetadataStore::global(cx)
                         .update(cx, |store, cx| store.delete(session_id, cx));
                 });
             }
@@ -4868,7 +4991,7 @@ mod property_test {
             anyhow::bail!("sidebar should still have an associated multi-workspace");
         };
         let workspaces = multi_workspace.read(cx).workspaces().to_vec();
-        let thread_store = SidebarThreadMetadataStore::global(cx);
+        let thread_store = ThreadMetadataStore::global(cx);
 
         let sidebar_thread_ids: HashSet<acp::SessionId> = sidebar
             .contents
@@ -4963,7 +5086,7 @@ mod property_test {
         cx.update(|cx| {
             cx.update_flags(false, vec!["agent-v2".into()]);
             ThreadStore::init_global(cx);
-            SidebarThreadMetadataStore::init_global(cx);
+            ThreadMetadataStore::init_global(cx);
             language_model::LanguageModelRegistry::test(cx);
             prompt_store::init(cx);
         });

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

@@ -7,7 +7,8 @@ use gpui::{
     Animation, AnimationExt, AnyView, ClickEvent, Hsla, MouseButton, SharedString,
     pulsating_between,
 };
-use std::time::Duration;
+use itertools::Itertools as _;
+use std::{path::PathBuf, sync::Arc, time::Duration};
 
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
 pub enum AgentThreadStatus {
@@ -44,6 +45,7 @@ pub struct ThreadItem {
     hovered: bool,
     added: Option<usize>,
     removed: Option<usize>,
+    project_paths: Option<Arc<[PathBuf]>>,
     worktrees: Vec<ThreadItemWorktreeInfo>,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
     on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
@@ -71,6 +73,7 @@ impl ThreadItem {
             hovered: false,
             added: None,
             removed: None,
+            project_paths: None,
             worktrees: Vec::new(),
             on_click: None,
             on_hover: Box::new(|_, _, _| {}),
@@ -149,6 +152,11 @@ impl ThreadItem {
         self
     }
 
+    pub fn project_paths(mut self, paths: Arc<[PathBuf]>) -> Self {
+        self.project_paths = Some(paths);
+        self
+    }
+
     pub fn worktrees(mut self, worktrees: Vec<ThreadItemWorktreeInfo>) -> Self {
         self.worktrees = worktrees;
         self
@@ -312,6 +320,21 @@ impl RenderOnce for ThreadItem {
         let added_count = self.added.unwrap_or(0);
         let removed_count = self.removed.unwrap_or(0);
 
+        let project_paths = self.project_paths.as_ref().and_then(|paths| {
+            let paths_str = paths
+                .as_ref()
+                .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 has_project_paths = project_paths.is_some();
         let has_worktree = !self.worktrees.is_empty();
         let has_timestamp = !self.timestamp.is_empty();
         let timestamp = self.timestamp;
@@ -429,10 +452,23 @@ impl RenderOnce for ThreadItem {
                         .min_w_0()
                         .gap_1p5()
                         .child(icon_container()) // Icon Spacing
-                        .children(worktree_chips)
-                        .when(has_worktree && (has_diff_stats || has_timestamp), |this| {
+                        .when_some(project_paths, |this, paths| {
+                            this.child(
+                                Label::new(paths)
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted)
+                                    .into_any_element(),
+                            )
+                        })
+                        .when(has_project_paths && has_worktree, |this| {
                             this.child(dot_separator())
                         })
+                        .children(worktree_chips)
+                        .when(
+                            (has_project_paths || has_worktree)
+                                && (has_diff_stats || has_timestamp),
+                            |this| this.child(dot_separator()),
+                        )
                         .when(has_diff_stats, |this| {
                             this.child(
                                 DiffStat::new(diff_stat_id, added_count, removed_count)

crates/util/src/path_list.rs 🔗

@@ -70,6 +70,11 @@ impl PathList {
         self.paths.as_ref()
     }
 
+    /// Get the paths in the lexicographic order.
+    pub fn paths_owned(&self) -> Arc<[PathBuf]> {
+        self.paths.clone()
+    }
+
     /// Get the order in which the paths were provided.
     pub fn order(&self) -> &[usize] {
         self.order.as_ref()