@@ -1,5 +1,6 @@
use std::{path::Path, sync::Arc};
+use acp_thread::AgentSessionInfo;
use agent::{ThreadStore, ZED_AGENT_ID};
use agent_client_protocol as acp;
use anyhow::Result;
@@ -83,6 +84,62 @@ pub struct ThreadMetadata {
pub folder_paths: PathList,
}
+impl ThreadMetadata {
+ pub fn from_session_info(agent_id: AgentId, session: &AgentSessionInfo) -> Self {
+ let session_id = session.session_id.clone();
+ let title = session.title.clone().unwrap_or_default();
+ let updated_at = session.updated_at.unwrap_or_else(|| Utc::now());
+ let created_at = session.created_at.unwrap_or(updated_at);
+ let folder_paths = session.work_dirs.clone().unwrap_or_default();
+ let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
+ None
+ } else {
+ Some(agent_id)
+ };
+ Self {
+ session_id,
+ agent_id,
+ title,
+ updated_at,
+ created_at: Some(created_at),
+ folder_paths,
+ }
+ }
+
+ pub fn from_thread(thread: &Entity<acp_thread::AcpThread>, cx: &App) -> Self {
+ let thread_ref = thread.read(cx);
+ let session_id = thread_ref.session_id().clone();
+ let title = thread_ref.title();
+ let updated_at = Utc::now();
+
+ let agent_id = thread_ref.connection().agent_id();
+
+ let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
+ None
+ } else {
+ Some(agent_id)
+ };
+
+ let folder_paths = {
+ let project = thread_ref.project().read(cx);
+ let paths: Vec<Arc<Path>> = project
+ .visible_worktrees(cx)
+ .map(|worktree| worktree.read(cx).abs_path())
+ .collect();
+ PathList::new(&paths)
+ };
+
+ Self {
+ session_id,
+ agent_id,
+ title,
+ created_at: Some(updated_at), // handled by db `ON CONFLICT`
+ updated_at,
+ folder_paths,
+ }
+ }
+}
+
pub struct ThreadMetadataStore {
db: ThreadMetadataDb,
session_subscriptions: HashMap<acp::SessionId, Subscription>,
@@ -119,6 +176,14 @@ impl ThreadMetadataStore {
cx.global::<GlobalThreadMetadataStore>().0.clone()
}
+ pub fn list_ids(&self, cx: &App) -> Task<Result<Vec<acp::SessionId>>> {
+ let db = self.db.clone();
+ cx.background_spawn(async move {
+ let s = db.list_ids()?;
+ Ok(s)
+ })
+ }
+
pub fn list(&self, cx: &App) -> Task<Result<Vec<ThreadMetadata>>> {
let db = self.db.clone();
cx.background_spawn(async move {
@@ -209,44 +274,12 @@ impl ThreadMetadataStore {
acp_thread::AcpThreadEvent::NewEntry
| acp_thread::AcpThreadEvent::EntryUpdated(_)
| acp_thread::AcpThreadEvent::TitleUpdated => {
- let metadata = Self::metadata_for_acp_thread(thread.read(cx), cx);
+ let metadata = ThreadMetadata::from_thread(&thread, cx);
self.save(metadata, cx).detach_and_log_err(cx);
}
_ => {}
}
}
-
- fn metadata_for_acp_thread(thread: &acp_thread::AcpThread, cx: &App) -> ThreadMetadata {
- let session_id = thread.session_id().clone();
- let title = thread.title();
- let updated_at = Utc::now();
-
- let agent_id = thread.connection().agent_id();
-
- let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() {
- None
- } else {
- Some(agent_id)
- };
-
- let folder_paths = {
- let project = thread.project().read(cx);
- let paths: Vec<Arc<Path>> = project
- .visible_worktrees(cx)
- .map(|worktree| worktree.read(cx).abs_path())
- .collect();
- PathList::new(&paths)
- };
-
- ThreadMetadata {
- session_id,
- agent_id,
- title,
- created_at: Some(updated_at), // handled by db `ON CONFLICT`
- updated_at,
- folder_paths,
- }
- }
}
impl Global for ThreadMetadataStore {}
@@ -272,6 +305,12 @@ impl Domain for ThreadMetadataDb {
db::static_connection!(ThreadMetadataDb, []);
impl ThreadMetadataDb {
+ /// List allsidebar thread session IDs
+ pub fn list_ids(&self) -> anyhow::Result<Vec<acp::SessionId>> {
+ self.select::<Arc<str>>("SELECT session_id FROM sidebar_threads")?()
+ .map(|ids| ids.into_iter().map(|id| acp::SessionId::new(id)).collect())
+ }
+
/// List all sidebar thread metadata, ordered by updated_at descending.
pub fn list(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
self.select::<ThreadMetadata>(
@@ -2,7 +2,7 @@ use std::sync::Arc;
use crate::{
Agent, RemoveSelectedThread, agent_connection_store::AgentConnectionStore,
- thread_history::ThreadHistory,
+ thread_history::ThreadHistory, thread_metadata_store::ThreadMetadataStore,
};
use acp_thread::AgentSessionInfo;
use agent::ThreadStore;
@@ -19,8 +19,8 @@ use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use project::{AgentId, AgentServerStore};
use theme::ActiveTheme;
use ui::{
- ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem,
- PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*,
+ ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, KeyBinding,
+ ListItem, PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*,
utils::platform_title_bar_height,
};
use util::ResultExt as _;
@@ -110,7 +110,7 @@ fn archive_empty_state_message(
pub enum ThreadsArchiveViewEvent {
Close,
- OpenThread {
+ Unarchive {
agent: Agent,
session_info: AgentSessionInfo,
},
@@ -135,6 +135,7 @@ pub struct ThreadsArchiveView {
_subscriptions: Vec<gpui::Subscription>,
selected_agent_menu: PopoverMenuHandle<ContextMenu>,
_refresh_history_task: Task<()>,
+ _update_items_task: Option<Task<()>>,
is_loading: bool,
}
@@ -179,6 +180,7 @@ impl ThreadsArchiveView {
_subscriptions: vec![filter_editor_subscription],
selected_agent_menu: PopoverMenuHandle::default(),
_refresh_history_task: Task::ready(()),
+ _update_items_task: None,
is_loading: true,
};
this.set_selected_agent(Agent::NativeAgent, window, cx);
@@ -241,42 +243,58 @@ impl ThreadsArchiveView {
let query = self.filter_editor.read(cx).text(cx).to_lowercase();
let today = Local::now().naive_local().date();
- let mut items = Vec::with_capacity(sessions.len() + 5);
- let mut current_bucket: Option<TimeBucket> = None;
+ self._update_items_task.take();
+ let unarchived_ids_task = ThreadMetadataStore::global(cx).read(cx).list_ids(cx);
+ self._update_items_task = Some(cx.spawn(async move |this, cx| {
+ let unarchived_session_ids = unarchived_ids_task.await.unwrap_or_default();
- for session in sessions {
- let highlight_positions = if !query.is_empty() {
- let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or("");
- match fuzzy_match_positions(&query, title) {
- Some(positions) => positions,
- None => continue,
+ let mut items = Vec::with_capacity(sessions.len() + 5);
+ let mut current_bucket: Option<TimeBucket> = None;
+
+ for session in sessions {
+ // Skip sessions that are shown in the sidebar
+ if unarchived_session_ids.contains(&session.session_id) {
+ continue;
}
- } else {
- Vec::new()
- };
-
- let entry_bucket = session
- .updated_at
- .map(|timestamp| {
- let entry_date = timestamp.with_timezone(&Local).naive_local().date();
- TimeBucket::from_dates(today, entry_date)
- })
- .unwrap_or(TimeBucket::Older);
-
- if Some(entry_bucket) != current_bucket {
- current_bucket = Some(entry_bucket);
- items.push(ArchiveListItem::BucketSeparator(entry_bucket));
- }
- items.push(ArchiveListItem::Entry {
- session,
- highlight_positions,
- });
- }
+ let highlight_positions = if !query.is_empty() {
+ let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or("");
+ match fuzzy_match_positions(&query, title) {
+ Some(positions) => positions,
+ None => continue,
+ }
+ } else {
+ Vec::new()
+ };
- self.list_state.reset(items.len());
- self.items = items;
- cx.notify();
+ let entry_bucket = session
+ .updated_at
+ .map(|timestamp| {
+ let entry_date = timestamp.with_timezone(&Local).naive_local().date();
+ TimeBucket::from_dates(today, entry_date)
+ })
+ .unwrap_or(TimeBucket::Older);
+
+ if Some(entry_bucket) != current_bucket {
+ current_bucket = Some(entry_bucket);
+ items.push(ArchiveListItem::BucketSeparator(entry_bucket));
+ }
+
+ items.push(ArchiveListItem::Entry {
+ session,
+ highlight_positions,
+ });
+ }
+
+ this.update(cx, |this, cx| {
+ this.list_state.reset(items.len());
+ this.items = items;
+ this.selection = None;
+ this.hovered_index = None;
+ cx.notify();
+ })
+ .ok();
+ }));
}
fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -290,7 +308,7 @@ impl ThreadsArchiveView {
cx.emit(ThreadsArchiveViewEvent::Close);
}
- fn open_thread(
+ fn unarchive_thread(
&mut self,
session_info: AgentSessionInfo,
window: &mut Window,
@@ -298,7 +316,7 @@ impl ThreadsArchiveView {
) {
self.selection = None;
self.reset_filter_editor_text(window, cx);
- cx.emit(ThreadsArchiveViewEvent::OpenThread {
+ cx.emit(ThreadsArchiveViewEvent::Unarchive {
agent: self.selected_agent.clone(),
session_info,
});
@@ -410,7 +428,7 @@ impl ThreadsArchiveView {
let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
return;
};
- self.open_thread(session.clone(), window, cx);
+ self.unarchive_thread(session.clone(), window, cx);
}
fn render_list_entry(
@@ -439,59 +457,54 @@ impl ThreadsArchiveView {
session,
highlight_positions,
} => {
- let is_selected = self.selection == Some(ix);
+ let id = SharedString::from(format!("archive-entry-{}", ix));
+
+ let is_focused = self.selection == Some(ix);
let hovered = self.hovered_index == Some(ix);
+
+ let project_names = session.work_dirs.as_ref().and_then(|paths| {
+ let paths_str = paths
+ .paths()
+ .iter()
+ .filter_map(|p| p.file_name())
+ .filter_map(|name| name.to_str())
+ .join(", ");
+ if paths_str.is_empty() {
+ None
+ } else {
+ Some(paths_str)
+ }
+ });
+
let supports_delete = self
.history
.as_ref()
.map(|h| h.read(cx).supports_delete())
.unwrap_or(false);
+
let title: SharedString =
session.title.clone().unwrap_or_else(|| "Untitled".into());
+
let session_info = session.clone();
let session_id_for_delete = session.session_id.clone();
let focus_handle = self.focus_handle.clone();
- let highlight_positions = highlight_positions.clone();
let timestamp = session
.created_at
.or(session.updated_at)
.map(format_history_entry_timestamp);
- let id = SharedString::from(format!("archive-entry-{}", ix));
-
+ let highlight_positions = highlight_positions.clone();
let title_label = if highlight_positions.is_empty() {
- Label::new(title)
- .size(LabelSize::Small)
- .truncate()
- .into_any_element()
+ Label::new(title).truncate().into_any_element()
} else {
HighlightedLabel::new(title, highlight_positions)
- .size(LabelSize::Small)
.truncate()
.into_any_element()
};
ListItem::new(id)
- .toggle_state(is_selected)
- .child(
- h_flex()
- .min_w_0()
- .w_full()
- .py_1()
- .pl_0p5()
- .pr_1p5()
- .gap_2()
- .justify_between()
- .child(title_label)
- .when(!(hovered && supports_delete), |this| {
- this.when_some(timestamp, |this, ts| {
- this.child(
- Label::new(ts).size(LabelSize::Small).color(Color::Muted),
- )
- })
- }),
- )
+ .focused(is_focused)
.on_hover(cx.listener(move |this, is_hovered, _window, cx| {
if *is_hovered {
this.hovered_index = Some(ix);
@@ -500,32 +513,84 @@ impl ThreadsArchiveView {
}
cx.notify();
}))
- .end_slot::<IconButton>(if hovered && supports_delete {
- Some(
- IconButton::new("delete-thread", IconName::Trash)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .tooltip({
- move |_window, cx| {
- Tooltip::for_action_in(
- "Delete Thread",
- &RemoveSelectedThread,
- &focus_handle,
- cx,
+ .child(
+ v_flex()
+ .min_w_0()
+ .w_full()
+ .py_1()
+ .pl_0p5()
+ .child(title_label)
+ .child(
+ h_flex()
+ .gap_1()
+ .when_some(timestamp, |this, ts| {
+ this.child(
+ Label::new(ts)
+ .size(LabelSize::Small)
+ .color(Color::Muted),
)
- }
- })
- .on_click(cx.listener(move |this, _, _, cx| {
- this.delete_thread(&session_id_for_delete, cx);
- cx.stop_propagation();
- })),
+ })
+ .when_some(project_names, |this, project| {
+ this.child(
+ Label::new("•")
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .alpha(0.5),
+ )
+ .child(
+ Label::new(project)
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ }),
+ ),
+ )
+ .when(hovered || is_focused, |this| {
+ this.end_slot(
+ h_flex()
+ .pr_2p5()
+ .gap_0p5()
+ .child(
+ Button::new("unarchive-thread", "Unarchive")
+ .style(ButtonStyle::OutlinedGhost)
+ .label_size(LabelSize::Small)
+ .when(is_focused, |this| {
+ this.key_binding(
+ KeyBinding::for_action_in(
+ &menu::Confirm,
+ &focus_handle,
+ cx,
+ )
+ .map(|kb| kb.size(rems_from_px(12.))),
+ )
+ })
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.unarchive_thread(session_info.clone(), window, cx);
+ })),
+ )
+ .when(supports_delete, |this| {
+ this.child(
+ IconButton::new("delete-thread", IconName::Trash)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .tooltip({
+ move |_window, cx| {
+ Tooltip::for_action_in(
+ "Delete Thread",
+ &RemoveSelectedThread,
+ &focus_handle,
+ cx,
+ )
+ }
+ })
+ .on_click(cx.listener(move |this, _, _, cx| {
+ this.delete_thread(&session_id_for_delete, cx);
+ cx.stop_propagation();
+ })),
+ )
+ }),
)
- } else {
- None
})
- .on_click(cx.listener(move |this, _, window, cx| {
- this.open_thread(session_info.clone(), window, cx);
- }))
.into_any_element()
}
}
@@ -697,8 +762,7 @@ impl ThreadsArchiveView {
.child(
h_flex()
.h(Tab::container_height(cx))
- .p_2()
- .pr_1p5()
+ .px_1p5()
.gap_1p5()
.border_b_1()
.border_color(cx.theme().colors().border)
@@ -1,6 +1,5 @@
use acp_thread::ThreadStatus;
use action_log::DiffStats;
-use agent::ThreadStore;
use agent_client_protocol::{self as acp};
use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
use agent_ui::threads_archive_view::{
@@ -1870,6 +1869,16 @@ impl Sidebar {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ // Eagerly save thread metadata so that the sidebar is updated immediately
+ ThreadMetadataStore::global(cx)
+ .update(cx, |store, cx| {
+ store.save(
+ ThreadMetadata::from_session_info(agent.id(), &session_info),
+ cx,
+ )
+ })
+ .detach_and_log_err(cx);
+
if let Some(path_list) = &session_info.work_dirs {
if let Some(workspace) = self.find_current_workspace_for_path_list(path_list, cx) {
self.activate_thread_locally(agent, session_info, &workspace, window, cx);
@@ -2040,13 +2049,13 @@ impl Sidebar {
}
}
- fn delete_thread(
+ fn archive_thread(
&mut self,
session_id: &acp::SessionId,
window: &mut Window,
cx: &mut Context<Self>,
) {
- // If we're deleting the currently focused thread, move focus to the
+ // If we're archiving the currently focused thread, move focus to the
// nearest thread within the same project group. We never cross group
// boundaries — if the group has no other threads, clear focus and open
// a blank new thread in the panel instead.
@@ -2058,7 +2067,7 @@ impl Sidebar {
// Find the workspace that owns this thread's project group by
// walking backwards to the nearest ProjectHeader. We must use
// *this* workspace (not the active workspace) because the user
- // might be deleting a thread in a non-active group.
+ // might be archiving a thread in a non-active group.
let group_workspace = current_pos.and_then(|pos| {
self.contents.entries[..pos]
.iter()
@@ -2133,15 +2142,6 @@ impl Sidebar {
}
}
- let Some(thread_store) = ThreadStore::try_global(cx) else {
- return;
- };
- thread_store.update(cx, |store, cx| {
- store
- .delete_thread(session_id.clone(), cx)
- .detach_and_log_err(cx);
- });
-
ThreadMetadataStore::global(cx)
.update(cx, |store, cx| store.delete(session_id.clone(), cx))
.detach_and_log_err(cx);
@@ -2163,7 +2163,7 @@ impl Sidebar {
return;
}
let session_id = thread.session_info.session_id.clone();
- self.delete_thread(&session_id, window, cx);
+ self.archive_thread(&session_id, window, cx);
}
fn render_thread(
@@ -2251,14 +2251,14 @@ impl Sidebar {
})
.when(is_hovered && can_delete && !is_running, |this| {
this.action_slot(
- IconButton::new("delete-thread", IconName::Trash)
+ IconButton::new("archive-thread", IconName::Archive)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip({
let focus_handle = focus_handle.clone();
move |_window, cx| {
Tooltip::for_action_in(
- "Delete Thread",
+ "Archive Thread",
&RemoveSelectedThread,
&focus_handle,
cx,
@@ -2268,7 +2268,7 @@ impl Sidebar {
.on_click({
let session_id = session_id_for_delete.clone();
cx.listener(move |this, _, window, cx| {
- this.delete_thread(&session_id, window, cx);
+ this.archive_thread(&session_id, window, cx);
})
}),
)
@@ -2659,7 +2659,7 @@ impl Sidebar {
ThreadsArchiveViewEvent::Close => {
this.show_thread_list(window, cx);
}
- ThreadsArchiveViewEvent::OpenThread {
+ ThreadsArchiveViewEvent::Unarchive {
agent,
session_info,
} => {