From 2ddb1e6c4d609c21a22baf2bef303f4f13f909dd Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:05:30 -0300 Subject: [PATCH] agent_ui: Add archive view to the sidebar (#51336) This PR adds a button to the bottom of the sidebar that opens the archive view, which at the moment, only shows the same, uncategorized thread list available in the regular agent panel's history view. Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com> Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> --- assets/icons/archive.svg | 5 + crates/agent_ui/src/agent_ui.rs | 1 + crates/agent_ui/src/sidebar.rs | 232 ++++--- crates/agent_ui/src/threads_archive_view.rs | 654 ++++++++++++++++++++ crates/icons/src/icons.rs | 1 + 5 files changed, 823 insertions(+), 70 deletions(-) create mode 100644 assets/icons/archive.svg create mode 100644 crates/agent_ui/src/threads_archive_view.rs diff --git a/assets/icons/archive.svg b/assets/icons/archive.svg new file mode 100644 index 0000000000000000000000000000000000000000..9ffe3f39d27c7fe5cbb532a4f263c8800398e96f --- /dev/null +++ b/assets/icons/archive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index ea70d155b79e190dcfe9138b620aff4415b6d935..db0cf873418ea38f8d5771c13b281528218fb94e 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -34,6 +34,7 @@ mod text_thread_editor; mod text_thread_history; mod thread_history; mod thread_history_view; +mod threads_archive_view; mod ui; use std::rc::Rc; diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index e204205819a8eb41a0624fb8a4a8ba9a96174add..2d4259717d160521ddd4884cbb6a1a1241456b64 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,3 +1,4 @@ +use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent}; use crate::{AgentPanel, AgentPanelEvent, NewThread}; use acp_thread::ThreadStatus; use action_log::DiffStats; @@ -6,19 +7,18 @@ use agent_client_protocol as acp; use agent_settings::AgentSettings; use chrono::Utc; use db::kvp::KEY_VALUE_STORE; -use editor::{Editor, EditorElement, EditorStyle}; +use editor::Editor; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ - Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, FontStyle, ListState, - Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px, - relative, rems, + Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels, + Render, SharedString, WeakEntity, Window, actions, list, prelude::*, px, }; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::Event as ProjectEvent; use settings::Settings; use std::collections::{HashMap, HashSet}; use std::mem; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; use ui::{ AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem, Tooltip, WithScrollbar, prelude::*, @@ -46,6 +46,13 @@ const MAX_WIDTH: Pixels = px(800.0); const DEFAULT_THREADS_SHOWN: usize = 5; const SIDEBAR_STATE_KEY: &str = "sidebar_state"; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum SidebarView { + #[default] + ThreadList, + Archive, +} + fn read_sidebar_open_state(multi_workspace_id: u64) -> bool { KEY_VALUE_STORE .scoped(SIDEBAR_STATE_KEY) @@ -212,6 +219,9 @@ pub struct Sidebar { active_entry_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, + view: SidebarView, + archive_view: Option>, + _subscriptions: Vec, } impl Sidebar { @@ -311,6 +321,9 @@ impl Sidebar { active_entry_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashMap::new(), + view: SidebarView::default(), + archive_view: None, + _subscriptions: Vec::new(), } } @@ -1323,28 +1336,8 @@ impl Sidebar { .into_any_element() } - fn render_filter_input(&self, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - font_style: FontStyle::Normal, - line_height: relative(1.3), - ..Default::default() - }; - - EditorElement::new( - &self.filter_editor, - EditorStyle { - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) + fn render_filter_input(&self) -> impl IntoElement { + self.filter_editor.clone() } fn render_view_more( @@ -1451,6 +1444,61 @@ impl Sidebar { .into_any_element() } + fn render_thread_list_header( + &self, + docked_right: bool, + cx: &mut Context, + ) -> impl IntoElement { + let has_query = self.has_filter_query(cx); + + h_flex() + .h(Tab::container_height(cx)) + .flex_none() + .gap_1p5() + .border_b_1() + .border_color(cx.theme().colors().border) + .when(!docked_right, |this| { + this.child(self.render_sidebar_toggle_button(false, cx)) + }) + .child(self.render_filter_input()) + .when(has_query, |this| { + this.when(!docked_right, |this| this.pr_1p5()).child( + IconButton::new("clear_filter", IconName::Close) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::text("Clear Search")) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_filter_editor_text(window, cx); + this.update_entries(cx); + })), + ) + }) + .when(docked_right, |this| { + this.pl_2() + .pr_0p5() + .child(self.render_sidebar_toggle_button(true, cx)) + }) + } + + fn render_thread_list_footer(&self, cx: &mut Context) -> impl IntoElement { + h_flex() + .p_1p5() + .border_t_1() + .border_color(cx.theme().colors().border) + .child( + Button::new("view-archive", "Archive") + .full_width() + .label_size(LabelSize::Small) + .style(ButtonStyle::Outlined) + .icon(IconName::Archive) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::Start) + .on_click(cx.listener(|this, _, window, cx| { + this.show_archive(window, cx); + })), + ) + } + fn render_sidebar_toggle_button( &self, docked_right: bool, @@ -1491,6 +1539,67 @@ impl Sidebar { self.is_open } + fn show_archive(&mut self, window: &mut Window, cx: &mut Context) { + let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| { + w.read(cx) + .workspaces() + .get(w.read(cx).active_workspace_index()) + .cloned() + }) else { + return; + }; + + let Some(agent_panel) = active_workspace.read(cx).panel::(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(); + + let archive_view = cx.new(|cx| { + ThreadsArchiveView::new( + agent_connection_store, + agent_server_store, + thread_store, + fs, + window, + cx, + ) + }); + let subscription = cx.subscribe_in( + &archive_view, + window, + |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event { + ThreadsArchiveViewEvent::Close => { + this.show_thread_list(window, cx); + } + ThreadsArchiveViewEvent::OpenThread(_session_info) => { + //TODO: Actually open thread once we support it + } + }, + ); + + self._subscriptions.push(subscription); + self.archive_view = Some(archive_view); + self.view = SidebarView::Archive; + cx.notify(); + } + + fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context) { + self.view = SidebarView::ThreadList; + self.archive_view = None; + self._subscriptions.clear(); + window.focus(&self.focus_handle, cx); + cx.notify(); + } + pub fn set_open(&mut self, open: bool, cx: &mut Context) { if self.is_open == open { return; @@ -1558,7 +1667,6 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let ui_font = theme::setup_ui_font(window, cx); - let has_query = self.has_filter_query(cx); let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right; let sticky_header = self.render_sticky_header(docked_right, window, cx); @@ -1579,50 +1687,34 @@ impl Render for Sidebar { .font(ui_font) .size_full() .bg(cx.theme().colors().surface_background) - .child( - h_flex() - .h(Tab::container_height(cx)) - .flex_none() - .gap_1p5() - .border_b_1() - .border_color(cx.theme().colors().border) - .when(!docked_right, |this| { - this.child(self.render_sidebar_toggle_button(false, cx)) - }) - .child(self.render_filter_input(cx)) - .when(has_query, |this| { - this.when(!docked_right, |this| this.pr_1p5()).child( - IconButton::new("clear_filter", IconName::Close) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::text("Clear Search")) - .on_click(cx.listener(|this, _, window, cx| { - this.reset_filter_editor_text(window, cx); - this.update_entries(cx); - })), - ) - }) - .when(docked_right, |this| { - this.pl_2() - .pr_0p5() - .child(self.render_sidebar_toggle_button(true, cx)) - }), - ) - .child( - v_flex() - .relative() - .flex_1() - .overflow_hidden() + .map(|this| match self.view { + SidebarView::ThreadList => this + .child(self.render_thread_list_header(docked_right, cx)) .child( - list( - self.list_state.clone(), - cx.processor(Self::render_list_entry), - ) - .flex_1() - .size_full(), + v_flex() + .relative() + .flex_1() + .overflow_hidden() + .child( + list( + self.list_state.clone(), + cx.processor(Self::render_list_entry), + ) + .flex_1() + .size_full(), + ) + .when_some(sticky_header, |this, header| this.child(header)) + .vertical_scrollbar_for(&self.list_state, window, cx), ) - .when_some(sticky_header, |this, header| this.child(header)) - .vertical_scrollbar_for(&self.list_state, window, cx), - ) + .child(self.render_thread_list_footer(cx)), + SidebarView::Archive => { + if let Some(archive_view) = &self.archive_view { + this.child(archive_view.clone()) + } else { + this + } + } + }) } } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..8ee0eedbd8702c7901258087af5d149fcf210648 --- /dev/null +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -0,0 +1,654 @@ +use std::sync::Arc; + +use crate::{Agent, agent_connection_store::AgentConnectionStore, thread_history::ThreadHistory}; +use acp_thread::AgentSessionInfo; +use agent::ThreadStore; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; +use editor::Editor; +use fs::Fs; +use gpui::{ + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render, + SharedString, Subscription, Task, Window, list, prelude::*, px, +}; +use itertools::Itertools as _; +use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; +use project::{AgentServerStore, ExternalAgentServerName}; +use theme::ActiveTheme; +use ui::{ + ButtonLike, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem, PopoverMenu, + PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, +}; +use util::ResultExt as _; +use zed_actions::editor::{MoveDown, MoveUp}; + +#[derive(Clone)] +enum ArchiveListItem { + BucketSeparator(TimeBucket), + Entry { + session: AgentSessionInfo, + highlight_positions: Vec, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + Older, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + let week = date.iso_week(); + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + let last_week = (reference - TimeDelta::days(7)).iso_week(); + if week == last_week { + return TimeBucket::PastWeek; + } + TimeBucket::Older + } + + fn label(&self) -> &'static str { + match self { + TimeBucket::Today => "Today", + TimeBucket::Yesterday => "Yesterday", + TimeBucket::ThisWeek => "This Week", + TimeBucket::PastWeek => "Past Week", + TimeBucket::Older => "Older", + } + } +} + +fn fuzzy_match_positions(query: &str, text: &str) -> Option> { + let query = query.to_lowercase(); + let text_lower = text.to_lowercase(); + let mut positions = Vec::new(); + let mut query_chars = query.chars().peekable(); + for (i, c) in text_lower.chars().enumerate() { + if query_chars.peek() == Some(&c) { + positions.push(i); + query_chars.next(); + } + } + if query_chars.peek().is_none() { + Some(positions) + } else { + None + } +} + +pub enum ThreadsArchiveViewEvent { + Close, + OpenThread(AgentSessionInfo), +} + +impl EventEmitter for ThreadsArchiveView {} + +pub struct ThreadsArchiveView { + agent_connection_store: Entity, + agent_server_store: Entity, + thread_store: Entity, + fs: Arc, + history: Option>, + _history_subscription: Subscription, + selected_agent: Agent, + focus_handle: FocusHandle, + list_state: ListState, + items: Vec, + selection: Option, + filter_editor: Entity, + _subscriptions: Vec, + selected_agent_menu: PopoverMenuHandle, + _refresh_history_task: Task<()>, +} + +impl ThreadsArchiveView { + pub fn new( + agent_connection_store: Entity, + agent_server_store: Entity, + thread_store: Entity, + fs: Arc, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + + let filter_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search archive…", window, cx); + editor + }); + + let filter_editor_subscription = + cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| { + if let editor::EditorEvent::BufferEdited = event { + this.update_items(cx); + } + }); + + 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, + filter_editor, + _subscriptions: vec![filter_editor_subscription], + selected_agent_menu: PopoverMenuHandle::default(), + _refresh_history_task: Task::ready(()), + }; + this.set_selected_agent(Agent::NativeAgent, cx); + this + } + + fn set_selected_agent(&mut self, agent: Agent, cx: &mut Context) { + self.selected_agent = agent.clone(); + + 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 set_history(&mut self, history: Entity, cx: &mut Context) { + self._history_subscription = cx.observe(&history, |this, _, cx| { + this.update_items(cx); + }); + history.update(cx, |history, cx| { + history.refresh_full_history(cx); + }); + self.history = Some(history); + self.update_items(cx); + cx.notify(); + } + + fn update_items(&mut self, cx: &mut Context) { + let Some(history) = self.history.as_ref() else { + return; + }; + + let sessions = history.read(cx).sessions().to_vec(); + let query = self.filter_editor.read(cx).text(cx).to_lowercase(); + let today = Local::now().naive_local().date(); + + let mut items = Vec::with_capacity(sessions.len() + 5); + let mut current_bucket: Option = None; + + 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, + } + } 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, + }); + } + + self.list_state.reset(items.len()); + self.items = items; + cx.notify(); + } + + fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context) { + self.filter_editor.update(cx, |editor, cx| { + editor.set_text("", window, cx); + }); + } + + fn go_back(&mut self, window: &mut Window, cx: &mut Context) { + self.reset_filter_editor_text(window, cx); + cx.emit(ThreadsArchiveViewEvent::Close); + } + + fn open_thread( + &mut self, + session_info: AgentSessionInfo, + window: &mut Window, + cx: &mut Context, + ) { + self.selection = None; + self.reset_filter_editor_text(window, cx); + cx.emit(ThreadsArchiveViewEvent::OpenThread(session_info)); + } + + fn is_selectable_item(&self, ix: usize) -> bool { + matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. })) + } + + fn find_next_selectable(&self, start: usize) -> Option { + (start..self.items.len()).find(|&i| self.is_selectable_item(i)) + } + + fn find_previous_selectable(&self, start: usize) -> Option { + (0..=start).rev().find(|&i| self.is_selectable_item(i)) + } + + fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { + self.select_next(&SelectNext, window, cx); + } + + fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { + self.select_previous(&SelectPrevious, window, cx); + } + + fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { + let next = match self.selection { + Some(ix) => self.find_next_selectable(ix + 1), + None => self.find_next_selectable(0), + }; + if let Some(next) = next { + self.selection = Some(next); + self.list_state.scroll_to_reveal_item(next); + cx.notify(); + } + } + + fn select_previous( + &mut self, + _: &SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + let prev = match self.selection { + Some(ix) if ix > 0 => self.find_previous_selectable(ix - 1), + None => { + let last = self.items.len().saturating_sub(1); + self.find_previous_selectable(last) + } + _ => return, + }; + if let Some(prev) = prev { + self.selection = Some(prev); + self.list_state.scroll_to_reveal_item(prev); + cx.notify(); + } + } + + fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { + if let Some(first) = self.find_next_selectable(0) { + self.selection = Some(first); + self.list_state.scroll_to_reveal_item(first); + cx.notify(); + } + } + + fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { + let last = self.items.len().saturating_sub(1); + if let Some(last) = self.find_previous_selectable(last) { + self.selection = Some(last); + self.list_state.scroll_to_reveal_item(last); + cx.notify(); + } + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let Some(ix) = self.selection else { return }; + let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else { + return; + }; + self.open_thread(session.clone(), window, cx); + } + + fn render_list_entry( + &mut self, + ix: usize, + _window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(item) = self.items.get(ix) else { + return div().into_any_element(); + }; + + match item { + ArchiveListItem::BucketSeparator(bucket) => div() + .w_full() + .px_2() + .pt_3() + .pb_1() + .child( + Label::new(bucket.label()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element(), + ArchiveListItem::Entry { + session, + highlight_positions, + } => { + let is_selected = self.selection == Some(ix); + let title: SharedString = + session.title.clone().unwrap_or_else(|| "Untitled".into()); + let session_info = session.clone(); + let highlight_positions = highlight_positions.clone(); + + let timestamp = session.created_at.or(session.updated_at).map(|entry_time| { + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + + let minutes = duration.num_minutes(); + let hours = duration.num_hours(); + let days = duration.num_days(); + let weeks = days / 7; + let months = days / 30; + + if minutes < 60 { + format!("{}m", minutes.max(1)) + } else if hours < 24 { + format!("{}h", hours) + } else if weeks < 4 { + format!("{}w", weeks.max(1)) + } else { + format!("{}mo", months.max(1)) + } + }); + + let id = SharedString::from(format!("archive-entry-{}", ix)); + + let title_label = if highlight_positions.is_empty() { + Label::new(title) + .size(LabelSize::Small) + .truncate() + .into_any_element() + } else { + HighlightedLabel::new(title, highlight_positions) + .size(LabelSize::Small) + .truncate() + .into_any_element() + }; + + ListItem::new(id) + .toggle_state(is_selected) + .disabled(true) + .child( + h_flex() + .min_w_0() + .w_full() + .py_1() + .pl_0p5() + .pr_1p5() + .gap_2() + .justify_between() + .child(title_label) + .when_some(timestamp, |this, ts| { + this.child( + Label::new(ts).size(LabelSize::Small).color(Color::Muted), + ) + }), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_thread(session_info.clone(), window, cx); + })) + .into_any_element() + } + } + } + + fn render_agent_picker(&self, cx: &mut Context) -> PopoverMenu { + let agent_server_store = self.agent_server_store.clone(); + + let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() { + (IconName::ChevronUp, Color::Accent) + } else { + (IconName::ChevronDown, Color::Muted) + }; + + let selected_agent_icon = if let Agent::Custom { name } = &self.selected_agent { + let store = agent_server_store.read(cx); + let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); + + 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) + }; + + 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 |_, cx| { + this.update(cx, |this, cx| { + this.set_selected_agent(Agent::NativeAgent, cx) + }) + .ok(); + } + }), + ) + .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: ExternalAgentServerName, + display_name: SharedString, + } + + let agent_items = agent_server_store + .external_agents() + .map(|name| { + let display_name = agent_server_store + .agent_display_name(name) + .or_else(|| { + registry_store_ref + .as_ref() + .and_then(|store| store.agent(name.0.as_ref())) + .map(|a| a.name().clone()) + }) + .unwrap_or_else(|| name.0.clone()); + AgentMenuItem { + id: name.clone(), + display_name, + } + }) + .sorted_unstable_by_key(|e| e.display_name.to_lowercase()) + .collect::>(); + + 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.0.as_str())) + .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 { + name: item.id.0.clone(), + }; + move |_, cx| { + this.update(cx, |this, cx| { + this.set_selected_agent(agent.clone(), 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), + }) + } + + fn render_header(&self, cx: &mut Context) -> impl IntoElement { + let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); + + h_flex() + .h(Tab::container_height(cx)) + .px_1() + .gap_1p5() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + h_flex() + .flex_1() + .w_full() + .gap_1p5() + .child( + IconButton::new("back", IconName::ArrowLeft) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Back to Sidebar")) + .on_click(cx.listener(|this, _, window, cx| { + this.go_back(window, cx); + })), + ) + .child(self.filter_editor.clone()) + .when(has_query, |this| { + this.border_r_1().child( + IconButton::new("clear_archive_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); + })), + ) + }), + ) + .child(self.render_agent_picker(cx)) + } +} + +impl Focusable for ThreadsArchiveView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ThreadsArchiveView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_empty = self.items.is_empty(); + let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); + + let empty_state_container = |label: SharedString| { + v_flex() + .flex_1() + .justify_center() + .items_center() + .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)) + }; + + v_flex() + .key_context("ThreadsArchiveView") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::editor_move_down)) + .on_action(cx.listener(Self::editor_move_up)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .size_full() + .bg(cx.theme().colors().surface_background) + .child(self.render_header(cx)) + .child(if is_empty && has_query { + empty_state_container("No threads match your search.".into()).into_any_element() + } else if is_empty { + empty_state_container("No archived threads yet.".into()).into_any_element() + } else { + v_flex() + .flex_1() + .overflow_hidden() + .child( + list( + self.list_state.clone(), + cx.processor(Self::render_list_entry), + ) + .flex_1() + .size_full(), + ) + .vertical_scrollbar_for(&self.list_state, window, cx) + .into_any_element() + }) + } +} diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 94fed7f03f46e64ef0ac929e60cf6ae848145e72..17db6371114e1623280c22a23dd44e8efc6fa594 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -27,6 +27,7 @@ pub enum IconName { AiVZero, AiXAi, AiZed, + Archive, ArrowCircle, ArrowDown, ArrowDown10,