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