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