threads_archive_view.rs

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