threads_archive_view.rs

  1use std::sync::Arc;
  2
  3use crate::{
  4    Agent, RemoveSelectedThread, agent_connection_store::AgentConnectionStore,
  5    thread_history::ThreadHistory,
  6};
  7use acp_thread::AgentSessionInfo;
  8use agent::ThreadStore;
  9use agent_client_protocol as acp;
 10use agent_settings::AgentSettings;
 11use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc};
 12use editor::Editor;
 13use fs::Fs;
 14use gpui::{
 15    AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render,
 16    SharedString, Subscription, Task, Window, list, prelude::*, px,
 17};
 18use itertools::Itertools as _;
 19use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
 20use project::{AgentId, AgentServerStore};
 21use settings::Settings as _;
 22use theme::ActiveTheme;
 23use ui::{
 24    ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, Divider, HighlightedLabel,
 25    KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*,
 26    utils::platform_title_bar_height,
 27};
 28use util::ResultExt as _;
 29use zed_actions::agents_sidebar::FocusSidebarFilter;
 30use zed_actions::editor::{MoveDown, MoveUp};
 31
 32#[derive(Clone)]
 33enum ArchiveListItem {
 34    BucketSeparator(TimeBucket),
 35    Entry {
 36        session: AgentSessionInfo,
 37        highlight_positions: Vec<usize>,
 38    },
 39}
 40
 41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 42enum TimeBucket {
 43    Today,
 44    Yesterday,
 45    ThisWeek,
 46    PastWeek,
 47    Older,
 48}
 49
 50impl TimeBucket {
 51    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
 52        if date == reference {
 53            return TimeBucket::Today;
 54        }
 55        if date == reference - TimeDelta::days(1) {
 56            return TimeBucket::Yesterday;
 57        }
 58        let week = date.iso_week();
 59        if reference.iso_week() == week {
 60            return TimeBucket::ThisWeek;
 61        }
 62        let last_week = (reference - TimeDelta::days(7)).iso_week();
 63        if week == last_week {
 64            return TimeBucket::PastWeek;
 65        }
 66        TimeBucket::Older
 67    }
 68
 69    fn label(&self) -> &'static str {
 70        match self {
 71            TimeBucket::Today => "Today",
 72            TimeBucket::Yesterday => "Yesterday",
 73            TimeBucket::ThisWeek => "This Week",
 74            TimeBucket::PastWeek => "Past Week",
 75            TimeBucket::Older => "Older",
 76        }
 77    }
 78}
 79
 80fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
 81    let query = query.to_lowercase();
 82    let text_lower = text.to_lowercase();
 83    let mut positions = Vec::new();
 84    let mut query_chars = query.chars().peekable();
 85    for (i, c) in text_lower.chars().enumerate() {
 86        if query_chars.peek() == Some(&c) {
 87            positions.push(i);
 88            query_chars.next();
 89        }
 90    }
 91    if query_chars.peek().is_none() {
 92        Some(positions)
 93    } else {
 94        None
 95    }
 96}
 97
 98fn archive_empty_state_message(
 99    has_history: bool,
100    is_empty: bool,
101    has_query: bool,
102) -> Option<&'static str> {
103    if !is_empty {
104        None
105    } else if !has_history {
106        Some("This agent does not support viewing archived threads.")
107    } else if has_query {
108        Some("No threads match your search.")
109    } else {
110        Some("No archived threads yet.")
111    }
112}
113
114pub enum ThreadsArchiveViewEvent {
115    Close,
116    Unarchive {
117        agent: Agent,
118        session_info: AgentSessionInfo,
119    },
120}
121
122impl EventEmitter<ThreadsArchiveViewEvent> for ThreadsArchiveView {}
123
124pub struct ThreadsArchiveView {
125    agent_connection_store: Entity<AgentConnectionStore>,
126    agent_server_store: Entity<AgentServerStore>,
127    thread_store: Entity<ThreadStore>,
128    fs: Arc<dyn Fs>,
129    history: Option<Entity<ThreadHistory>>,
130    _history_subscription: Subscription,
131    selected_agent: Agent,
132    focus_handle: FocusHandle,
133    list_state: ListState,
134    items: Vec<ArchiveListItem>,
135    selection: Option<usize>,
136    hovered_index: Option<usize>,
137    filter_editor: Entity<Editor>,
138    _subscriptions: Vec<gpui::Subscription>,
139    selected_agent_menu: PopoverMenuHandle<ContextMenu>,
140    _refresh_history_task: Task<()>,
141    is_loading: bool,
142}
143
144impl ThreadsArchiveView {
145    pub fn new(
146        agent_connection_store: Entity<AgentConnectionStore>,
147        agent_server_store: Entity<AgentServerStore>,
148        thread_store: Entity<ThreadStore>,
149        fs: Arc<dyn Fs>,
150        window: &mut Window,
151        cx: &mut Context<Self>,
152    ) -> Self {
153        let focus_handle = cx.focus_handle();
154
155        let filter_editor = cx.new(|cx| {
156            let mut editor = Editor::single_line(window, cx);
157            editor.set_placeholder_text("Search archive…", window, cx);
158            editor
159        });
160
161        let filter_editor_subscription =
162            cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
163                if let editor::EditorEvent::BufferEdited = event {
164                    this.update_items(cx);
165                }
166            });
167
168        let filter_focus_handle = filter_editor.read(cx).focus_handle(cx);
169        cx.on_focus_in(
170            &filter_focus_handle,
171            window,
172            |this: &mut Self, _window, cx| {
173                if this.selection.is_some() {
174                    this.selection = None;
175                    cx.notify();
176                }
177            },
178        )
179        .detach();
180
181        cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, _window, cx| {
182            this.selection = None;
183            cx.notify();
184        })
185        .detach();
186
187        let mut this = Self {
188            agent_connection_store,
189            agent_server_store,
190            thread_store,
191            fs,
192            history: None,
193            _history_subscription: Subscription::new(|| {}),
194            selected_agent: Agent::NativeAgent,
195            focus_handle,
196            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
197            items: Vec::new(),
198            selection: None,
199            hovered_index: None,
200            filter_editor,
201            _subscriptions: vec![filter_editor_subscription],
202            selected_agent_menu: PopoverMenuHandle::default(),
203            _refresh_history_task: Task::ready(()),
204            is_loading: true,
205        };
206        this.set_selected_agent(Agent::NativeAgent, window, cx);
207        this
208    }
209
210    pub fn has_selection(&self) -> bool {
211        self.selection.is_some()
212    }
213
214    pub fn clear_selection(&mut self) {
215        self.selection = None;
216    }
217
218    pub fn focus_filter_editor(&self, window: &mut Window, cx: &mut App) {
219        let handle = self.filter_editor.read(cx).focus_handle(cx);
220        handle.focus(window, cx);
221    }
222
223    fn set_selected_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
224        self.selected_agent = agent.clone();
225        self.is_loading = true;
226        self.reset_history_subscription();
227        self.history = None;
228        self.items.clear();
229        self.selection = None;
230        self.list_state.reset(0);
231        self.reset_filter_editor_text(window, cx);
232
233        let server = agent.server(self.fs.clone(), self.thread_store.clone());
234        let connection = self
235            .agent_connection_store
236            .update(cx, |store, cx| store.request_connection(agent, server, cx));
237
238        let task = connection.read(cx).wait_for_connection();
239        self._refresh_history_task = cx.spawn(async move |this, cx| {
240            if let Some(state) = task.await.log_err() {
241                this.update(cx, |this, cx| this.set_history(state.history, cx))
242                    .ok();
243            }
244        });
245
246        cx.notify();
247    }
248
249    fn reset_history_subscription(&mut self) {
250        self._history_subscription = Subscription::new(|| {});
251    }
252
253    fn set_history(&mut self, history: Option<Entity<ThreadHistory>>, cx: &mut Context<Self>) {
254        self.reset_history_subscription();
255
256        if let Some(history) = &history {
257            self._history_subscription = cx.observe(history, |this, _, cx| {
258                this.update_items(cx);
259            });
260            history.update(cx, |history, cx| {
261                history.refresh_full_history(cx);
262            });
263        }
264        self.history = history;
265        self.is_loading = false;
266        self.update_items(cx);
267        cx.notify();
268    }
269
270    fn update_items(&mut self, cx: &mut Context<Self>) {
271        let sessions = self
272            .history
273            .as_ref()
274            .map(|h| h.read(cx).sessions().to_vec())
275            .unwrap_or_default();
276        let query = self.filter_editor.read(cx).text(cx).to_lowercase();
277        let today = Local::now().naive_local().date();
278
279        let mut items = Vec::with_capacity(sessions.len() + 5);
280        let mut current_bucket: Option<TimeBucket> = None;
281
282        for session in sessions {
283            let highlight_positions = if !query.is_empty() {
284                let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or("");
285                match fuzzy_match_positions(&query, title) {
286                    Some(positions) => positions,
287                    None => continue,
288                }
289            } else {
290                Vec::new()
291            };
292
293            let entry_bucket = session
294                .updated_at
295                .map(|timestamp| {
296                    let entry_date = timestamp.with_timezone(&Local).naive_local().date();
297                    TimeBucket::from_dates(today, entry_date)
298                })
299                .unwrap_or(TimeBucket::Older);
300
301            if Some(entry_bucket) != current_bucket {
302                current_bucket = Some(entry_bucket);
303                items.push(ArchiveListItem::BucketSeparator(entry_bucket));
304            }
305
306            items.push(ArchiveListItem::Entry {
307                session,
308                highlight_positions,
309            });
310        }
311
312        self.list_state.reset(items.len());
313        self.items = items;
314        self.selection = None;
315        self.hovered_index = None;
316        cx.notify();
317    }
318
319    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
320        self.filter_editor.update(cx, |editor, cx| {
321            editor.set_text("", window, cx);
322        });
323    }
324
325    fn unarchive_thread(
326        &mut self,
327        session_info: AgentSessionInfo,
328        window: &mut Window,
329        cx: &mut Context<Self>,
330    ) {
331        self.selection = None;
332        self.reset_filter_editor_text(window, cx);
333        cx.emit(ThreadsArchiveViewEvent::Unarchive {
334            agent: self.selected_agent.clone(),
335            session_info,
336        });
337    }
338
339    fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
340        let Some(history) = &self.history else {
341            return;
342        };
343        if !history.read(cx).supports_delete() {
344            return;
345        }
346        let session_id = session_id.clone();
347        history.update(cx, |history, cx| {
348            history
349                .delete_session(&session_id, cx)
350                .detach_and_log_err(cx);
351        });
352    }
353
354    fn remove_selected_thread(
355        &mut self,
356        _: &RemoveSelectedThread,
357        _window: &mut Window,
358        cx: &mut Context<Self>,
359    ) {
360        let Some(ix) = self.selection else {
361            return;
362        };
363        let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
364            return;
365        };
366        let session_id = session.session_id.clone();
367        self.delete_thread(&session_id, cx);
368    }
369
370    fn is_selectable_item(&self, ix: usize) -> bool {
371        matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
372    }
373
374    fn find_next_selectable(&self, start: usize) -> Option<usize> {
375        (start..self.items.len()).find(|&i| self.is_selectable_item(i))
376    }
377
378    fn find_previous_selectable(&self, start: usize) -> Option<usize> {
379        (0..=start).rev().find(|&i| self.is_selectable_item(i))
380    }
381
382    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
383        self.select_next(&SelectNext, window, cx);
384        if self.selection.is_some() {
385            self.focus_handle.focus(window, cx);
386        }
387    }
388
389    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
390        self.select_previous(&SelectPrevious, window, cx);
391        if self.selection.is_some() {
392            self.focus_handle.focus(window, cx);
393        }
394    }
395
396    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
397        let next = match self.selection {
398            Some(ix) => self.find_next_selectable(ix + 1),
399            None => self.find_next_selectable(0),
400        };
401        if let Some(next) = next {
402            self.selection = Some(next);
403            self.list_state.scroll_to_reveal_item(next);
404            cx.notify();
405        }
406    }
407
408    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
409        match self.selection {
410            Some(ix) => {
411                if let Some(prev) = (ix > 0)
412                    .then(|| self.find_previous_selectable(ix - 1))
413                    .flatten()
414                {
415                    self.selection = Some(prev);
416                    self.list_state.scroll_to_reveal_item(prev);
417                } else {
418                    self.selection = None;
419                    self.focus_filter_editor(window, cx);
420                }
421                cx.notify();
422            }
423            None => {
424                let last = self.items.len().saturating_sub(1);
425                if let Some(prev) = self.find_previous_selectable(last) {
426                    self.selection = Some(prev);
427                    self.list_state.scroll_to_reveal_item(prev);
428                    cx.notify();
429                }
430            }
431        }
432    }
433
434    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
435        if let Some(first) = self.find_next_selectable(0) {
436            self.selection = Some(first);
437            self.list_state.scroll_to_reveal_item(first);
438            cx.notify();
439        }
440    }
441
442    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
443        let last = self.items.len().saturating_sub(1);
444        if let Some(last) = self.find_previous_selectable(last) {
445            self.selection = Some(last);
446            self.list_state.scroll_to_reveal_item(last);
447            cx.notify();
448        }
449    }
450
451    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
452        let Some(ix) = self.selection else { return };
453        let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
454            return;
455        };
456
457        let can_unarchive = session.work_dirs.as_ref().is_some_and(|p| !p.is_empty());
458        if !can_unarchive {
459            return;
460        }
461
462        self.unarchive_thread(session.clone(), window, cx);
463    }
464
465    fn render_list_entry(
466        &mut self,
467        ix: usize,
468        _window: &mut Window,
469        cx: &mut Context<Self>,
470    ) -> AnyElement {
471        let Some(item) = self.items.get(ix) else {
472            return div().into_any_element();
473        };
474
475        match item {
476            ArchiveListItem::BucketSeparator(bucket) => div()
477                .w_full()
478                .px_2p5()
479                .pt_3()
480                .pb_1()
481                .child(
482                    Label::new(bucket.label())
483                        .size(LabelSize::Small)
484                        .color(Color::Muted),
485                )
486                .into_any_element(),
487            ArchiveListItem::Entry {
488                session,
489                highlight_positions,
490            } => {
491                let id = SharedString::from(format!("archive-entry-{}", ix));
492
493                let is_focused = self.selection == Some(ix);
494                let hovered = self.hovered_index == Some(ix);
495
496                let project_names = session.work_dirs.as_ref().and_then(|paths| {
497                    let paths_str = paths
498                        .paths()
499                        .iter()
500                        .filter_map(|p| p.file_name())
501                        .filter_map(|name| name.to_str())
502                        .join(", ");
503                    if paths_str.is_empty() {
504                        None
505                    } else {
506                        Some(paths_str)
507                    }
508                });
509
510                let can_unarchive = session.work_dirs.as_ref().is_some_and(|p| !p.is_empty());
511
512                let supports_delete = self
513                    .history
514                    .as_ref()
515                    .map(|h| h.read(cx).supports_delete())
516                    .unwrap_or(false);
517
518                let title: SharedString =
519                    session.title.clone().unwrap_or_else(|| "Untitled".into());
520
521                let session_info = session.clone();
522                let session_id_for_delete = session.session_id.clone();
523                let focus_handle = self.focus_handle.clone();
524
525                let timestamp = session
526                    .created_at
527                    .or(session.updated_at)
528                    .map(format_history_entry_timestamp);
529
530                let highlight_positions = highlight_positions.clone();
531                let title_label = if highlight_positions.is_empty() {
532                    Label::new(title).truncate().flex_1().into_any_element()
533                } else {
534                    HighlightedLabel::new(title, highlight_positions)
535                        .truncate()
536                        .flex_1()
537                        .into_any_element()
538                };
539
540                h_flex()
541                    .id(id)
542                    .min_w_0()
543                    .w_full()
544                    .px(DynamicSpacing::Base06.rems(cx))
545                    .border_1()
546                    .map(|this| {
547                        if is_focused {
548                            this.border_color(cx.theme().colors().border_focused)
549                        } else {
550                            this.border_color(gpui::transparent_black())
551                        }
552                    })
553                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
554                        if *is_hovered {
555                            this.hovered_index = Some(ix);
556                        } else if this.hovered_index == Some(ix) {
557                            this.hovered_index = None;
558                        }
559                        cx.notify();
560                    }))
561                    .child(
562                        v_flex()
563                            .min_w_0()
564                            .w_full()
565                            .p_1()
566                            .child(
567                                h_flex()
568                                    .min_w_0()
569                                    .w_full()
570                                    .gap_1()
571                                    .justify_between()
572                                    .child(title_label)
573                                    .when(hovered || is_focused, |this| {
574                                        this.child(
575                                            h_flex()
576                                                .gap_0p5()
577                                                .when(can_unarchive, |this| {
578                                                    this.child(
579                                                        Button::new("unarchive-thread", "Restore")
580                                                            .style(ButtonStyle::Filled)
581                                                            .label_size(LabelSize::Small)
582                                                            .when(is_focused, |this| {
583                                                                this.key_binding(
584                                                                    KeyBinding::for_action_in(
585                                                                        &menu::Confirm,
586                                                                        &focus_handle,
587                                                                        cx,
588                                                                    )
589                                                                    .map(|kb| {
590                                                                        kb.size(rems_from_px(12.))
591                                                                    }),
592                                                                )
593                                                            })
594                                                            .on_click(cx.listener(
595                                                                move |this, _, window, cx| {
596                                                                    this.unarchive_thread(
597                                                                        session_info.clone(),
598                                                                        window,
599                                                                        cx,
600                                                                    );
601                                                                },
602                                                            )),
603                                                    )
604                                                })
605                                                .when(supports_delete, |this| {
606                                                    this.child(
607                                                        IconButton::new(
608                                                            "delete-thread",
609                                                            IconName::Trash,
610                                                        )
611                                                        .style(ButtonStyle::Filled)
612                                                        .icon_size(IconSize::Small)
613                                                        .icon_color(Color::Muted)
614                                                        .tooltip({
615                                                            move |_window, cx| {
616                                                                Tooltip::for_action_in(
617                                                                    "Delete Thread",
618                                                                    &RemoveSelectedThread,
619                                                                    &focus_handle,
620                                                                    cx,
621                                                                )
622                                                            }
623                                                        })
624                                                        .on_click(cx.listener(
625                                                            move |this, _, _, cx| {
626                                                                this.delete_thread(
627                                                                    &session_id_for_delete,
628                                                                    cx,
629                                                                );
630                                                                cx.stop_propagation();
631                                                            },
632                                                        )),
633                                                    )
634                                                }),
635                                        )
636                                    }),
637                            )
638                            .child(
639                                h_flex()
640                                    .gap_1()
641                                    .when_some(timestamp, |this, ts| {
642                                        this.child(
643                                            Label::new(ts)
644                                                .size(LabelSize::Small)
645                                                .color(Color::Muted),
646                                        )
647                                    })
648                                    .when_some(project_names, |this, project| {
649                                        this.child(
650                                            Label::new("")
651                                                .size(LabelSize::Small)
652                                                .color(Color::Muted)
653                                                .alpha(0.5),
654                                        )
655                                        .child(
656                                            Label::new(project)
657                                                .size(LabelSize::Small)
658                                                .color(Color::Muted),
659                                        )
660                                    }),
661                            ),
662                    )
663                    .into_any_element()
664            }
665        }
666    }
667
668    fn render_agent_picker(&self, cx: &mut Context<Self>) -> PopoverMenu<ContextMenu> {
669        let agent_server_store = self.agent_server_store.clone();
670
671        let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() {
672            (IconName::ChevronUp, Color::Accent)
673        } else {
674            (IconName::ChevronDown, Color::Muted)
675        };
676
677        let selected_agent_icon = if let Agent::Custom { id } = &self.selected_agent {
678            let store = agent_server_store.read(cx);
679            let icon = store.agent_icon(&id);
680
681            if let Some(icon) = icon {
682                Icon::from_external_svg(icon)
683            } else {
684                Icon::new(IconName::Sparkle)
685            }
686            .color(Color::Muted)
687            .size(IconSize::Small)
688        } else {
689            Icon::new(IconName::ZedAgent)
690                .color(Color::Muted)
691                .size(IconSize::Small)
692        };
693
694        let this = cx.weak_entity();
695
696        PopoverMenu::new("agent_history_menu")
697            .trigger(
698                ButtonLike::new("selected_agent")
699                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
700                    .child(
701                        h_flex().gap_1().child(selected_agent_icon).child(
702                            Icon::new(chevron_icon)
703                                .color(icon_color)
704                                .size(IconSize::XSmall),
705                        ),
706                    ),
707            )
708            .menu(move |window, cx| {
709                Some(ContextMenu::build(window, cx, |menu, _window, cx| {
710                    menu.item(
711                        ContextMenuEntry::new("Zed Agent")
712                            .icon(IconName::ZedAgent)
713                            .icon_color(Color::Muted)
714                            .handler({
715                                let this = this.clone();
716                                move |window, cx| {
717                                    this.update(cx, |this, cx| {
718                                        this.set_selected_agent(Agent::NativeAgent, window, cx)
719                                    })
720                                    .ok();
721                                }
722                            }),
723                    )
724                    .separator()
725                    .map(|mut menu| {
726                        let agent_server_store = agent_server_store.read(cx);
727                        let registry_store = project::AgentRegistryStore::try_global(cx);
728                        let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
729
730                        struct AgentMenuItem {
731                            id: AgentId,
732                            display_name: SharedString,
733                        }
734
735                        let agent_items = agent_server_store
736                            .external_agents()
737                            .map(|agent_id| {
738                                let display_name = agent_server_store
739                                    .agent_display_name(agent_id)
740                                    .or_else(|| {
741                                        registry_store_ref
742                                            .as_ref()
743                                            .and_then(|store| store.agent(agent_id))
744                                            .map(|a| a.name().clone())
745                                    })
746                                    .unwrap_or_else(|| agent_id.0.clone());
747                                AgentMenuItem {
748                                    id: agent_id.clone(),
749                                    display_name,
750                                }
751                            })
752                            .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
753                            .collect::<Vec<_>>();
754
755                        for item in &agent_items {
756                            let mut entry = ContextMenuEntry::new(item.display_name.clone());
757
758                            let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| {
759                                registry_store_ref
760                                    .as_ref()
761                                    .and_then(|store| store.agent(&item.id))
762                                    .and_then(|a| a.icon_path().cloned())
763                            });
764
765                            if let Some(icon_path) = icon_path {
766                                entry = entry.custom_icon_svg(icon_path);
767                            } else {
768                                entry = entry.icon(IconName::ZedAgent);
769                            }
770
771                            entry = entry.icon_color(Color::Muted).handler({
772                                let this = this.clone();
773                                let agent = Agent::Custom {
774                                    id: item.id.clone(),
775                                };
776                                move |window, cx| {
777                                    this.update(cx, |this, cx| {
778                                        this.set_selected_agent(agent.clone(), window, cx)
779                                    })
780                                    .ok();
781                                }
782                            });
783
784                            menu = menu.item(entry);
785                        }
786                        menu
787                    })
788                }))
789            })
790            .with_handle(self.selected_agent_menu.clone())
791            .anchor(gpui::Corner::TopRight)
792            .offset(gpui::Point {
793                x: px(1.0),
794                y: px(1.0),
795            })
796    }
797
798    fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
799        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
800        let sidebar_on_left = matches!(
801            AgentSettings::get_global(cx).sidebar_side(),
802            settings::SidebarSide::Left
803        );
804        let traffic_lights =
805            cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left;
806        let header_height = platform_title_bar_height(window);
807        let show_focus_keybinding =
808            self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window);
809
810        h_flex()
811            .h(header_height)
812            .mt_px()
813            .pb_px()
814            .map(|this| {
815                if traffic_lights {
816                    this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
817                } else {
818                    this.pl_1p5()
819                }
820            })
821            .pr_1p5()
822            .gap_1()
823            .justify_between()
824            .border_b_1()
825            .border_color(cx.theme().colors().border)
826            .when(traffic_lights, |this| {
827                this.child(Divider::vertical().color(ui::DividerColor::Border))
828            })
829            .child(
830                h_flex()
831                    .ml_1()
832                    .min_w_0()
833                    .w_full()
834                    .gap_1()
835                    .child(
836                        Icon::new(IconName::MagnifyingGlass)
837                            .size(IconSize::Small)
838                            .color(Color::Muted),
839                    )
840                    .child(self.filter_editor.clone()),
841            )
842            .when(show_focus_keybinding, |this| {
843                this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
844            })
845            .when(!has_query && !show_focus_keybinding, |this| {
846                this.child(self.render_agent_picker(cx))
847            })
848            .when(has_query, |this| {
849                this.child(
850                    IconButton::new("clear_filter", IconName::Close)
851                        .icon_size(IconSize::Small)
852                        .tooltip(Tooltip::text("Clear Search"))
853                        .on_click(cx.listener(|this, _, window, cx| {
854                            this.reset_filter_editor_text(window, cx);
855                            this.update_items(cx);
856                        })),
857                )
858            })
859    }
860}
861
862pub fn format_history_entry_timestamp(entry_time: DateTime<Utc>) -> String {
863    let now = Utc::now();
864    let duration = now.signed_duration_since(entry_time);
865
866    let minutes = duration.num_minutes();
867    let hours = duration.num_hours();
868    let days = duration.num_days();
869    let weeks = days / 7;
870    let months = days / 30;
871
872    if minutes < 60 {
873        format!("{}m", minutes.max(1))
874    } else if hours < 24 {
875        format!("{}h", hours.max(1))
876    } else if days < 7 {
877        format!("{}d", days.max(1))
878    } else if weeks < 4 {
879        format!("{}w", weeks.max(1))
880    } else {
881        format!("{}mo", months.max(1))
882    }
883}
884
885impl Focusable for ThreadsArchiveView {
886    fn focus_handle(&self, _cx: &App) -> FocusHandle {
887        self.focus_handle.clone()
888    }
889}
890
891impl ThreadsArchiveView {
892    fn empty_state_message(&self, is_empty: bool, has_query: bool) -> Option<&'static str> {
893        archive_empty_state_message(self.history.is_some(), is_empty, has_query)
894    }
895}
896
897impl Render for ThreadsArchiveView {
898    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
899        let is_empty = self.items.is_empty();
900        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
901
902        let content = if self.is_loading {
903            v_flex()
904                .flex_1()
905                .justify_center()
906                .items_center()
907                .child(
908                    Icon::new(IconName::LoadCircle)
909                        .size(IconSize::Small)
910                        .color(Color::Muted)
911                        .with_rotate_animation(2),
912                )
913                .into_any_element()
914        } else if let Some(message) = self.empty_state_message(is_empty, has_query) {
915            v_flex()
916                .flex_1()
917                .justify_center()
918                .items_center()
919                .child(
920                    Label::new(message)
921                        .size(LabelSize::Small)
922                        .color(Color::Muted),
923                )
924                .into_any_element()
925        } else {
926            v_flex()
927                .flex_1()
928                .overflow_hidden()
929                .child(
930                    list(
931                        self.list_state.clone(),
932                        cx.processor(Self::render_list_entry),
933                    )
934                    .flex_1()
935                    .size_full(),
936                )
937                .vertical_scrollbar_for(&self.list_state, window, cx)
938                .into_any_element()
939        };
940
941        v_flex()
942            .key_context("ThreadsArchiveView")
943            .track_focus(&self.focus_handle)
944            .on_action(cx.listener(Self::select_next))
945            .on_action(cx.listener(Self::select_previous))
946            .on_action(cx.listener(Self::editor_move_down))
947            .on_action(cx.listener(Self::editor_move_up))
948            .on_action(cx.listener(Self::select_first))
949            .on_action(cx.listener(Self::select_last))
950            .on_action(cx.listener(Self::confirm))
951            .on_action(cx.listener(Self::remove_selected_thread))
952            .size_full()
953            .child(self.render_header(window, cx))
954            .child(content)
955    }
956}
957
958#[cfg(test)]
959mod tests {
960    use super::archive_empty_state_message;
961
962    #[test]
963    fn empty_state_message_returns_none_when_archive_has_items() {
964        assert_eq!(archive_empty_state_message(false, false, false), None);
965        assert_eq!(archive_empty_state_message(true, false, true), None);
966    }
967
968    #[test]
969    fn empty_state_message_distinguishes_unsupported_history() {
970        assert_eq!(
971            archive_empty_state_message(false, true, false),
972            Some("This agent does not support viewing archived threads.")
973        );
974        assert_eq!(
975            archive_empty_state_message(false, true, true),
976            Some("This agent does not support viewing archived threads.")
977        );
978    }
979
980    #[test]
981    fn empty_state_message_distinguishes_empty_history_and_search_results() {
982        assert_eq!(
983            archive_empty_state_message(true, true, false),
984            Some("No archived threads yet.")
985        );
986        assert_eq!(
987            archive_empty_state_message(true, true, true),
988            Some("No threads match your search.")
989        );
990    }
991}