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,