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::OutlinedGhost)
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                                                        .icon_size(IconSize::Small)
610                                                        .icon_color(Color::Muted)
611                                                        .tooltip({
612                                                            move |_window, cx| {
613                                                                Tooltip::for_action_in(
614                                                                    "Delete Thread",
615                                                                    &RemoveSelectedThread,
616                                                                    &focus_handle,
617                                                                    cx,
618                                                                )
619                                                            }
620                                                        })
621                                                        .on_click(cx.listener(
622                                                            move |this, _, _, cx| {
623                                                                this.delete_thread(
624                                                                    &session_id_for_delete,
625                                                                    cx,
626                                                                );
627                                                                cx.stop_propagation();
628                                                            },
629                                                        )),
630                                                    )
631                                                }),
632                                        )
633                                    }),
634                            )
635                            .child(
636                                h_flex()
637                                    .gap_1()
638                                    .when_some(timestamp, |this, ts| {
639                                        this.child(
640                                            Label::new(ts)
641                                                .size(LabelSize::Small)
642                                                .color(Color::Muted),
643                                        )
644                                    })
645                                    .when_some(project_names, |this, project| {
646                                        this.child(
647                                            Label::new("")
648                                                .size(LabelSize::Small)
649                                                .color(Color::Muted)
650                                                .alpha(0.5),
651                                        )
652                                        .child(
653                                            Label::new(project)
654                                                .size(LabelSize::Small)
655                                                .color(Color::Muted),
656                                        )
657                                    }),
658                            ),
659                    )
660                    .into_any_element()
661            }
662        }
663    }
664
665    fn render_agent_picker(&self, cx: &mut Context<Self>) -> PopoverMenu<ContextMenu> {
666        let agent_server_store = self.agent_server_store.clone();
667
668        let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() {
669            (IconName::ChevronUp, Color::Accent)
670        } else {
671            (IconName::ChevronDown, Color::Muted)
672        };
673
674        let selected_agent_icon = if let Agent::Custom { id } = &self.selected_agent {
675            let store = agent_server_store.read(cx);
676            let icon = store.agent_icon(&id);
677
678            if let Some(icon) = icon {
679                Icon::from_external_svg(icon)
680            } else {
681                Icon::new(IconName::Sparkle)
682            }
683            .color(Color::Muted)
684            .size(IconSize::Small)
685        } else {
686            Icon::new(IconName::ZedAgent)
687                .color(Color::Muted)
688                .size(IconSize::Small)
689        };
690
691        let this = cx.weak_entity();
692
693        PopoverMenu::new("agent_history_menu")
694            .trigger(
695                ButtonLike::new("selected_agent")
696                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
697                    .child(
698                        h_flex().gap_1().child(selected_agent_icon).child(
699                            Icon::new(chevron_icon)
700                                .color(icon_color)
701                                .size(IconSize::XSmall),
702                        ),
703                    ),
704            )
705            .menu(move |window, cx| {
706                Some(ContextMenu::build(window, cx, |menu, _window, cx| {
707                    menu.item(
708                        ContextMenuEntry::new("Zed Agent")
709                            .icon(IconName::ZedAgent)
710                            .icon_color(Color::Muted)
711                            .handler({
712                                let this = this.clone();
713                                move |window, cx| {
714                                    this.update(cx, |this, cx| {
715                                        this.set_selected_agent(Agent::NativeAgent, window, cx)
716                                    })
717                                    .ok();
718                                }
719                            }),
720                    )
721                    .separator()
722                    .map(|mut menu| {
723                        let agent_server_store = agent_server_store.read(cx);
724                        let registry_store = project::AgentRegistryStore::try_global(cx);
725                        let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
726
727                        struct AgentMenuItem {
728                            id: AgentId,
729                            display_name: SharedString,
730                        }
731
732                        let agent_items = agent_server_store
733                            .external_agents()
734                            .map(|agent_id| {
735                                let display_name = agent_server_store
736                                    .agent_display_name(agent_id)
737                                    .or_else(|| {
738                                        registry_store_ref
739                                            .as_ref()
740                                            .and_then(|store| store.agent(agent_id))
741                                            .map(|a| a.name().clone())
742                                    })
743                                    .unwrap_or_else(|| agent_id.0.clone());
744                                AgentMenuItem {
745                                    id: agent_id.clone(),
746                                    display_name,
747                                }
748                            })
749                            .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
750                            .collect::<Vec<_>>();
751
752                        for item in &agent_items {
753                            let mut entry = ContextMenuEntry::new(item.display_name.clone());
754
755                            let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| {
756                                registry_store_ref
757                                    .as_ref()
758                                    .and_then(|store| store.agent(&item.id))
759                                    .and_then(|a| a.icon_path().cloned())
760                            });
761
762                            if let Some(icon_path) = icon_path {
763                                entry = entry.custom_icon_svg(icon_path);
764                            } else {
765                                entry = entry.icon(IconName::ZedAgent);
766                            }
767
768                            entry = entry.icon_color(Color::Muted).handler({
769                                let this = this.clone();
770                                let agent = Agent::Custom {
771                                    id: item.id.clone(),
772                                };
773                                move |window, cx| {
774                                    this.update(cx, |this, cx| {
775                                        this.set_selected_agent(agent.clone(), window, cx)
776                                    })
777                                    .ok();
778                                }
779                            });
780
781                            menu = menu.item(entry);
782                        }
783                        menu
784                    })
785                }))
786            })
787            .with_handle(self.selected_agent_menu.clone())
788            .anchor(gpui::Corner::TopRight)
789            .offset(gpui::Point {
790                x: px(1.0),
791                y: px(1.0),
792            })
793    }
794
795    fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
796        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
797        let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
798        let header_height = platform_title_bar_height(window);
799        let show_focus_keybinding =
800            self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window);
801
802        h_flex()
803            .h(header_height)
804            .mt_px()
805            .pb_px()
806            .when(traffic_lights, |this| {
807                this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
808            })
809            .pr_1p5()
810            .gap_1()
811            .justify_between()
812            .border_b_1()
813            .border_color(cx.theme().colors().border)
814            .child(Divider::vertical().color(ui::DividerColor::Border))
815            .child(
816                h_flex()
817                    .ml_1()
818                    .min_w_0()
819                    .w_full()
820                    .gap_1()
821                    .child(
822                        Icon::new(IconName::MagnifyingGlass)
823                            .size(IconSize::Small)
824                            .color(Color::Muted),
825                    )
826                    .child(self.filter_editor.clone()),
827            )
828            .when(show_focus_keybinding, |this| {
829                this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
830            })
831            .when(!has_query && !show_focus_keybinding, |this| {
832                this.child(self.render_agent_picker(cx))
833            })
834            .when(has_query, |this| {
835                this.child(
836                    IconButton::new("clear_filter", IconName::Close)
837                        .icon_size(IconSize::Small)
838                        .tooltip(Tooltip::text("Clear Search"))
839                        .on_click(cx.listener(|this, _, window, cx| {
840                            this.reset_filter_editor_text(window, cx);
841                            this.update_items(cx);
842                        })),
843                )
844            })
845    }
846}
847
848pub fn format_history_entry_timestamp(entry_time: DateTime<Utc>) -> String {
849    let now = Utc::now();
850    let duration = now.signed_duration_since(entry_time);
851
852    let minutes = duration.num_minutes();
853    let hours = duration.num_hours();
854    let days = duration.num_days();
855    let weeks = days / 7;
856    let months = days / 30;
857
858    if minutes < 60 {
859        format!("{}m", minutes.max(1))
860    } else if hours < 24 {
861        format!("{}h", hours.max(1))
862    } else if days < 7 {
863        format!("{}d", days.max(1))
864    } else if weeks < 4 {
865        format!("{}w", weeks.max(1))
866    } else {
867        format!("{}mo", months.max(1))
868    }
869}
870
871impl Focusable for ThreadsArchiveView {
872    fn focus_handle(&self, _cx: &App) -> FocusHandle {
873        self.focus_handle.clone()
874    }
875}
876
877impl ThreadsArchiveView {
878    fn empty_state_message(&self, is_empty: bool, has_query: bool) -> Option<&'static str> {
879        archive_empty_state_message(self.history.is_some(), is_empty, has_query)
880    }
881}
882
883impl Render for ThreadsArchiveView {
884    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
885        let is_empty = self.items.is_empty();
886        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
887
888        let content = if self.is_loading {
889            v_flex()
890                .flex_1()
891                .justify_center()
892                .items_center()
893                .child(
894                    Icon::new(IconName::LoadCircle)
895                        .size(IconSize::Small)
896                        .color(Color::Muted)
897                        .with_rotate_animation(2),
898                )
899                .into_any_element()
900        } else if let Some(message) = self.empty_state_message(is_empty, has_query) {
901            v_flex()
902                .flex_1()
903                .justify_center()
904                .items_center()
905                .child(
906                    Label::new(message)
907                        .size(LabelSize::Small)
908                        .color(Color::Muted),
909                )
910                .into_any_element()
911        } else {
912            v_flex()
913                .flex_1()
914                .overflow_hidden()
915                .child(
916                    list(
917                        self.list_state.clone(),
918                        cx.processor(Self::render_list_entry),
919                    )
920                    .flex_1()
921                    .size_full(),
922                )
923                .vertical_scrollbar_for(&self.list_state, window, cx)
924                .into_any_element()
925        };
926
927        v_flex()
928            .key_context("ThreadsArchiveView")
929            .track_focus(&self.focus_handle)
930            .on_action(cx.listener(Self::select_next))
931            .on_action(cx.listener(Self::select_previous))
932            .on_action(cx.listener(Self::editor_move_down))
933            .on_action(cx.listener(Self::editor_move_up))
934            .on_action(cx.listener(Self::select_first))
935            .on_action(cx.listener(Self::select_last))
936            .on_action(cx.listener(Self::confirm))
937            .on_action(cx.listener(Self::remove_selected_thread))
938            .size_full()
939            .child(self.render_header(window, cx))
940            .child(content)
941    }
942}
943
944#[cfg(test)]
945mod tests {
946    use super::archive_empty_state_message;
947
948    #[test]
949    fn empty_state_message_returns_none_when_archive_has_items() {
950        assert_eq!(archive_empty_state_message(false, false, false), None);
951        assert_eq!(archive_empty_state_message(true, false, true), None);
952    }
953
954    #[test]
955    fn empty_state_message_distinguishes_unsupported_history() {
956        assert_eq!(
957            archive_empty_state_message(false, true, false),
958            Some("This agent does not support viewing archived threads.")
959        );
960        assert_eq!(
961            archive_empty_state_message(false, true, true),
962            Some("This agent does not support viewing archived threads.")
963        );
964    }
965
966    #[test]
967    fn empty_state_message_distinguishes_empty_history_and_search_results() {
968        assert_eq!(
969            archive_empty_state_message(true, true, false),
970            Some("No archived threads yet.")
971        );
972        assert_eq!(
973            archive_empty_state_message(true, true, true),
974            Some("No threads match your search.")
975        );
976    }
977}