thread_history.rs

  1use crate::{AgentPanel, RemoveSelectedThread};
  2use agent::history_store::{HistoryEntry, HistoryStore};
  3use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
  4use editor::{Editor, EditorEvent};
  5use fuzzy::{StringMatch, StringMatchCandidate};
  6use gpui::{
  7    App, ClickEvent, Empty, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task,
  8    UniformListScrollHandle, WeakEntity, Window, uniform_list,
  9};
 10use std::{fmt::Display, ops::Range, sync::Arc};
 11use time::{OffsetDateTime, UtcOffset};
 12use ui::{
 13    HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState,
 14    Tooltip, prelude::*,
 15};
 16use util::ResultExt;
 17
 18pub struct ThreadHistory {
 19    agent_panel: WeakEntity<AgentPanel>,
 20    history_store: Entity<HistoryStore>,
 21    scroll_handle: UniformListScrollHandle,
 22    selected_index: usize,
 23    hovered_index: Option<usize>,
 24    search_editor: Entity<Editor>,
 25    all_entries: Arc<Vec<HistoryEntry>>,
 26    // When the search is empty, we display date separators between history entries
 27    // This vector contains an enum of either a separator or an actual entry
 28    separated_items: Vec<ListItemType>,
 29    // Maps entry indexes to list item indexes
 30    separated_item_indexes: Vec<u32>,
 31    _separated_items_task: Option<Task<()>>,
 32    search_state: SearchState,
 33    scrollbar_visibility: bool,
 34    scrollbar_state: ScrollbarState,
 35    _subscriptions: Vec<gpui::Subscription>,
 36}
 37
 38enum SearchState {
 39    Empty,
 40    Searching {
 41        query: SharedString,
 42        _task: Task<()>,
 43    },
 44    Searched {
 45        query: SharedString,
 46        matches: Vec<StringMatch>,
 47    },
 48}
 49
 50enum ListItemType {
 51    BucketSeparator(TimeBucket),
 52    Entry {
 53        index: usize,
 54        format: EntryTimeFormat,
 55    },
 56}
 57
 58impl ListItemType {
 59    fn entry_index(&self) -> Option<usize> {
 60        match self {
 61            ListItemType::BucketSeparator(_) => None,
 62            ListItemType::Entry { index, .. } => Some(*index),
 63        }
 64    }
 65}
 66
 67impl ThreadHistory {
 68    pub(crate) fn new(
 69        agent_panel: WeakEntity<AgentPanel>,
 70        history_store: Entity<HistoryStore>,
 71        window: &mut Window,
 72        cx: &mut Context<Self>,
 73    ) -> Self {
 74        let search_editor = cx.new(|cx| {
 75            let mut editor = Editor::single_line(window, cx);
 76            editor.set_placeholder_text("Search threads...", cx);
 77            editor
 78        });
 79
 80        let search_editor_subscription =
 81            cx.subscribe(&search_editor, |this, search_editor, event, cx| {
 82                if let EditorEvent::BufferEdited = event {
 83                    let query = search_editor.read(cx).text(cx);
 84                    this.search(query.into(), cx);
 85                }
 86            });
 87
 88        let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
 89            this.update_all_entries(cx);
 90        });
 91
 92        let scroll_handle = UniformListScrollHandle::default();
 93        let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
 94
 95        let mut this = Self {
 96            agent_panel,
 97            history_store,
 98            scroll_handle,
 99            selected_index: 0,
100            hovered_index: None,
101            search_state: SearchState::Empty,
102            all_entries: Default::default(),
103            separated_items: Default::default(),
104            separated_item_indexes: Default::default(),
105            search_editor,
106            scrollbar_visibility: true,
107            scrollbar_state,
108            _subscriptions: vec![search_editor_subscription, history_store_subscription],
109            _separated_items_task: None,
110        };
111        this.update_all_entries(cx);
112        this
113    }
114
115    fn update_all_entries(&mut self, cx: &mut Context<Self>) {
116        let new_entries: Arc<Vec<HistoryEntry>> = self
117            .history_store
118            .update(cx, |store, cx| store.entries(cx))
119            .into();
120
121        self._separated_items_task.take();
122
123        let mut items = Vec::with_capacity(new_entries.len() + 1);
124        let mut indexes = Vec::with_capacity(new_entries.len() + 1);
125
126        let bg_task = cx.background_spawn(async move {
127            let mut bucket = None;
128            let today = Local::now().naive_local().date();
129
130            for (index, entry) in new_entries.iter().enumerate() {
131                let entry_date = entry
132                    .updated_at()
133                    .with_timezone(&Local)
134                    .naive_local()
135                    .date();
136                let entry_bucket = TimeBucket::from_dates(today, entry_date);
137
138                if Some(entry_bucket) != bucket {
139                    bucket = Some(entry_bucket);
140                    items.push(ListItemType::BucketSeparator(entry_bucket));
141                }
142
143                indexes.push(items.len() as u32);
144                items.push(ListItemType::Entry {
145                    index,
146                    format: entry_bucket.into(),
147                });
148            }
149            (new_entries, items, indexes)
150        });
151
152        let task = cx.spawn(async move |this, cx| {
153            let (new_entries, items, indexes) = bg_task.await;
154            this.update(cx, |this, cx| {
155                let previously_selected_entry =
156                    this.all_entries.get(this.selected_index).map(|e| e.id());
157
158                this.all_entries = new_entries;
159                this.separated_items = items;
160                this.separated_item_indexes = indexes;
161
162                match &this.search_state {
163                    SearchState::Empty => {
164                        if this.selected_index >= this.all_entries.len() {
165                            this.set_selected_entry_index(
166                                this.all_entries.len().saturating_sub(1),
167                                cx,
168                            );
169                        } else if let Some(prev_id) = previously_selected_entry {
170                            if let Some(new_ix) = this
171                                .all_entries
172                                .iter()
173                                .position(|probe| probe.id() == prev_id)
174                            {
175                                this.set_selected_entry_index(new_ix, cx);
176                            }
177                        }
178                    }
179                    SearchState::Searching { query, .. } | SearchState::Searched { query, .. } => {
180                        this.search(query.clone(), cx);
181                    }
182                }
183
184                cx.notify();
185            })
186            .log_err();
187        });
188        self._separated_items_task = Some(task);
189    }
190
191    fn search(&mut self, query: SharedString, cx: &mut Context<Self>) {
192        if query.is_empty() {
193            self.search_state = SearchState::Empty;
194            cx.notify();
195            return;
196        }
197
198        let all_entries = self.all_entries.clone();
199
200        let fuzzy_search_task = cx.background_spawn({
201            let query = query.clone();
202            let executor = cx.background_executor().clone();
203            async move {
204                let mut candidates = Vec::with_capacity(all_entries.len());
205
206                for (idx, entry) in all_entries.iter().enumerate() {
207                    match entry {
208                        HistoryEntry::Thread(thread) => {
209                            candidates.push(StringMatchCandidate::new(idx, &thread.summary));
210                        }
211                        HistoryEntry::Context(context) => {
212                            candidates.push(StringMatchCandidate::new(idx, &context.title));
213                        }
214                    }
215                }
216
217                const MAX_MATCHES: usize = 100;
218
219                fuzzy::match_strings(
220                    &candidates,
221                    &query,
222                    false,
223                    true,
224                    MAX_MATCHES,
225                    &Default::default(),
226                    executor,
227                )
228                .await
229            }
230        });
231
232        let task = cx.spawn({
233            let query = query.clone();
234            async move |this, cx| {
235                let matches = fuzzy_search_task.await;
236
237                this.update(cx, |this, cx| {
238                    let SearchState::Searching {
239                        query: current_query,
240                        _task,
241                    } = &this.search_state
242                    else {
243                        return;
244                    };
245
246                    if &query == current_query {
247                        this.search_state = SearchState::Searched {
248                            query: query.clone(),
249                            matches,
250                        };
251
252                        this.set_selected_entry_index(0, cx);
253                        cx.notify();
254                    };
255                })
256                .log_err();
257            }
258        });
259
260        self.search_state = SearchState::Searching { query, _task: task };
261        cx.notify();
262    }
263
264    fn matched_count(&self) -> usize {
265        match &self.search_state {
266            SearchState::Empty => self.all_entries.len(),
267            SearchState::Searching { .. } => 0,
268            SearchState::Searched { matches, .. } => matches.len(),
269        }
270    }
271
272    fn list_item_count(&self) -> usize {
273        match &self.search_state {
274            SearchState::Empty => self.separated_items.len(),
275            SearchState::Searching { .. } => 0,
276            SearchState::Searched { matches, .. } => matches.len(),
277        }
278    }
279
280    fn search_produced_no_matches(&self) -> bool {
281        match &self.search_state {
282            SearchState::Empty => false,
283            SearchState::Searching { .. } => false,
284            SearchState::Searched { matches, .. } => matches.is_empty(),
285        }
286    }
287
288    fn get_match(&self, ix: usize) -> Option<&HistoryEntry> {
289        match &self.search_state {
290            SearchState::Empty => self.all_entries.get(ix),
291            SearchState::Searching { .. } => None,
292            SearchState::Searched { matches, .. } => matches
293                .get(ix)
294                .and_then(|m| self.all_entries.get(m.candidate_id)),
295        }
296    }
297
298    pub fn select_previous(
299        &mut self,
300        _: &menu::SelectPrevious,
301        _window: &mut Window,
302        cx: &mut Context<Self>,
303    ) {
304        let count = self.matched_count();
305        if count > 0 {
306            if self.selected_index == 0 {
307                self.set_selected_entry_index(count - 1, cx);
308            } else {
309                self.set_selected_entry_index(self.selected_index - 1, cx);
310            }
311        }
312    }
313
314    pub fn select_next(
315        &mut self,
316        _: &menu::SelectNext,
317        _window: &mut Window,
318        cx: &mut Context<Self>,
319    ) {
320        let count = self.matched_count();
321        if count > 0 {
322            if self.selected_index == count - 1 {
323                self.set_selected_entry_index(0, cx);
324            } else {
325                self.set_selected_entry_index(self.selected_index + 1, cx);
326            }
327        }
328    }
329
330    fn select_first(
331        &mut self,
332        _: &menu::SelectFirst,
333        _window: &mut Window,
334        cx: &mut Context<Self>,
335    ) {
336        let count = self.matched_count();
337        if count > 0 {
338            self.set_selected_entry_index(0, cx);
339        }
340    }
341
342    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
343        let count = self.matched_count();
344        if count > 0 {
345            self.set_selected_entry_index(count - 1, cx);
346        }
347    }
348
349    fn set_selected_entry_index(&mut self, entry_index: usize, cx: &mut Context<Self>) {
350        self.selected_index = entry_index;
351
352        let scroll_ix = match self.search_state {
353            SearchState::Empty | SearchState::Searching { .. } => self
354                .separated_item_indexes
355                .get(entry_index)
356                .map(|ix| *ix as usize)
357                .unwrap_or(entry_index + 1),
358            SearchState::Searched { .. } => entry_index,
359        };
360
361        self.scroll_handle
362            .scroll_to_item(scroll_ix, ScrollStrategy::Top);
363
364        cx.notify();
365    }
366
367    fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
368        if !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) {
369            return None;
370        }
371
372        Some(
373            div()
374                .occlude()
375                .id("thread-history-scroll")
376                .h_full()
377                .bg(cx.theme().colors().panel_background.opacity(0.8))
378                .border_l_1()
379                .border_color(cx.theme().colors().border_variant)
380                .absolute()
381                .right_1()
382                .top_0()
383                .bottom_0()
384                .w_4()
385                .pl_1()
386                .cursor_default()
387                .on_mouse_move(cx.listener(|_, _, _window, cx| {
388                    cx.notify();
389                    cx.stop_propagation()
390                }))
391                .on_hover(|_, _window, cx| {
392                    cx.stop_propagation();
393                })
394                .on_any_mouse_down(|_, _window, cx| {
395                    cx.stop_propagation();
396                })
397                .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
398                    cx.notify();
399                }))
400                .children(Scrollbar::vertical(self.scrollbar_state.clone())),
401        )
402    }
403
404    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
405        if let Some(entry) = self.get_match(self.selected_index) {
406            let task_result = match entry {
407                HistoryEntry::Thread(thread) => self.agent_panel.update(cx, move |this, cx| {
408                    this.open_thread_by_id(&thread.id, window, cx)
409                }),
410                HistoryEntry::Context(context) => self.agent_panel.update(cx, move |this, cx| {
411                    this.open_saved_prompt_editor(context.path.clone(), window, cx)
412                }),
413            };
414
415            if let Some(task) = task_result.log_err() {
416                task.detach_and_log_err(cx);
417            };
418
419            cx.notify();
420        }
421    }
422
423    fn remove_selected_thread(
424        &mut self,
425        _: &RemoveSelectedThread,
426        _window: &mut Window,
427        cx: &mut Context<Self>,
428    ) {
429        if let Some(entry) = self.get_match(self.selected_index) {
430            let task_result = match entry {
431                HistoryEntry::Thread(thread) => self
432                    .agent_panel
433                    .update(cx, |this, cx| this.delete_thread(&thread.id, cx)),
434                HistoryEntry::Context(context) => self
435                    .agent_panel
436                    .update(cx, |this, cx| this.delete_context(context.path.clone(), cx)),
437            };
438
439            if let Some(task) = task_result.log_err() {
440                task.detach_and_log_err(cx);
441            };
442
443            cx.notify();
444        }
445    }
446
447    fn list_items(
448        &mut self,
449        range: Range<usize>,
450        _window: &mut Window,
451        cx: &mut Context<Self>,
452    ) -> Vec<AnyElement> {
453        let range_start = range.start;
454
455        match &self.search_state {
456            SearchState::Empty => self
457                .separated_items
458                .get(range)
459                .iter()
460                .flat_map(|items| {
461                    items
462                        .iter()
463                        .map(|item| self.render_list_item(item.entry_index(), item, vec![], cx))
464                })
465                .collect(),
466            SearchState::Searched { matches, .. } => matches[range]
467                .iter()
468                .enumerate()
469                .map(|(ix, m)| {
470                    self.render_list_item(
471                        Some(range_start + ix),
472                        &ListItemType::Entry {
473                            index: m.candidate_id,
474                            format: EntryTimeFormat::DateAndTime,
475                        },
476                        m.positions.clone(),
477                        cx,
478                    )
479                })
480                .collect(),
481            SearchState::Searching { .. } => {
482                vec![]
483            }
484        }
485    }
486
487    fn render_list_item(
488        &self,
489        list_entry_ix: Option<usize>,
490        item: &ListItemType,
491        highlight_positions: Vec<usize>,
492        cx: &Context<Self>,
493    ) -> AnyElement {
494        match item {
495            ListItemType::Entry { index, format } => match self.all_entries.get(*index) {
496                Some(entry) => h_flex()
497                    .w_full()
498                    .pb_1()
499                    .child(
500                        HistoryEntryElement::new(entry.clone(), self.agent_panel.clone())
501                            .highlight_positions(highlight_positions)
502                            .timestamp_format(*format)
503                            .selected(list_entry_ix == Some(self.selected_index))
504                            .hovered(list_entry_ix == self.hovered_index)
505                            .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
506                                if *is_hovered {
507                                    this.hovered_index = list_entry_ix;
508                                } else if this.hovered_index == list_entry_ix {
509                                    this.hovered_index = None;
510                                }
511
512                                cx.notify();
513                            }))
514                            .into_any_element(),
515                    )
516                    .into_any(),
517                None => Empty.into_any_element(),
518            },
519            ListItemType::BucketSeparator(bucket) => div()
520                .px(DynamicSpacing::Base06.rems(cx))
521                .pt_2()
522                .pb_1()
523                .child(
524                    Label::new(bucket.to_string())
525                        .size(LabelSize::XSmall)
526                        .color(Color::Muted),
527                )
528                .into_any_element(),
529        }
530    }
531}
532
533impl Focusable for ThreadHistory {
534    fn focus_handle(&self, cx: &App) -> FocusHandle {
535        self.search_editor.focus_handle(cx)
536    }
537}
538
539impl Render for ThreadHistory {
540    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
541        v_flex()
542            .key_context("ThreadHistory")
543            .size_full()
544            .bg(cx.theme().colors().panel_background)
545            .on_action(cx.listener(Self::select_previous))
546            .on_action(cx.listener(Self::select_next))
547            .on_action(cx.listener(Self::select_first))
548            .on_action(cx.listener(Self::select_last))
549            .on_action(cx.listener(Self::confirm))
550            .on_action(cx.listener(Self::remove_selected_thread))
551            .when(!self.all_entries.is_empty(), |parent| {
552                parent.child(
553                    h_flex()
554                        .h(px(41.)) // Match the toolbar perfectly
555                        .w_full()
556                        .py_1()
557                        .px_2()
558                        .gap_2()
559                        .justify_between()
560                        .border_b_1()
561                        .border_color(cx.theme().colors().border)
562                        .child(
563                            Icon::new(IconName::MagnifyingGlass)
564                                .color(Color::Muted)
565                                .size(IconSize::Small),
566                        )
567                        .child(self.search_editor.clone()),
568                )
569            })
570            .child({
571                let view = v_flex()
572                    .id("list-container")
573                    .relative()
574                    .overflow_hidden()
575                    .flex_grow();
576
577                if self.all_entries.is_empty() {
578                    view.justify_center()
579                        .child(
580                            h_flex().w_full().justify_center().child(
581                                Label::new("You don't have any past threads yet.")
582                                    .size(LabelSize::Small),
583                            ),
584                        )
585                } else if self.search_produced_no_matches() {
586                    view.justify_center().child(
587                        h_flex().w_full().justify_center().child(
588                            Label::new("No threads match your search.").size(LabelSize::Small),
589                        ),
590                    )
591                } else {
592                    view.pr_5()
593                        .child(
594                            uniform_list(
595                                "thread-history",
596                                self.list_item_count(),
597                                cx.processor(|this, range: Range<usize>, window, cx| {
598                                    this.list_items(range, window, cx)
599                                }),
600                            )
601                            .p_1()
602                            .track_scroll(self.scroll_handle.clone())
603                            .flex_grow(),
604                        )
605                        .when_some(self.render_scrollbar(cx), |div, scrollbar| {
606                            div.child(scrollbar)
607                        })
608                }
609            })
610    }
611}
612
613#[derive(IntoElement)]
614pub struct HistoryEntryElement {
615    entry: HistoryEntry,
616    agent_panel: WeakEntity<AgentPanel>,
617    selected: bool,
618    hovered: bool,
619    highlight_positions: Vec<usize>,
620    timestamp_format: EntryTimeFormat,
621    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
622}
623
624impl HistoryEntryElement {
625    pub fn new(entry: HistoryEntry, agent_panel: WeakEntity<AgentPanel>) -> Self {
626        Self {
627            entry,
628            agent_panel,
629            selected: false,
630            hovered: false,
631            highlight_positions: vec![],
632            timestamp_format: EntryTimeFormat::DateAndTime,
633            on_hover: Box::new(|_, _, _| {}),
634        }
635    }
636
637    pub fn selected(mut self, selected: bool) -> Self {
638        self.selected = selected;
639        self
640    }
641
642    pub fn hovered(mut self, hovered: bool) -> Self {
643        self.hovered = hovered;
644        self
645    }
646
647    pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
648        self.highlight_positions = positions;
649        self
650    }
651
652    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
653        self.on_hover = Box::new(on_hover);
654        self
655    }
656
657    pub fn timestamp_format(mut self, format: EntryTimeFormat) -> Self {
658        self.timestamp_format = format;
659        self
660    }
661}
662
663impl RenderOnce for HistoryEntryElement {
664    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
665        let (id, summary, timestamp) = match &self.entry {
666            HistoryEntry::Thread(thread) => (
667                thread.id.to_string(),
668                thread.summary.clone(),
669                thread.updated_at.timestamp(),
670            ),
671            HistoryEntry::Context(context) => (
672                context.path.to_string_lossy().to_string(),
673                context.title.clone(),
674                context.mtime.timestamp(),
675            ),
676        };
677
678        let thread_timestamp =
679            self.timestamp_format
680                .format_timestamp(&self.agent_panel, timestamp, cx);
681
682        ListItem::new(SharedString::from(id))
683            .rounded()
684            .toggle_state(self.selected)
685            .spacing(ListItemSpacing::Sparse)
686            .start_slot(
687                h_flex()
688                    .w_full()
689                    .gap_2()
690                    .justify_between()
691                    .child(
692                        HighlightedLabel::new(summary, self.highlight_positions)
693                            .size(LabelSize::Small)
694                            .truncate(),
695                    )
696                    .child(
697                        Label::new(thread_timestamp)
698                            .color(Color::Muted)
699                            .size(LabelSize::XSmall),
700                    ),
701            )
702            .on_hover(self.on_hover)
703            .end_slot::<IconButton>(if self.hovered || self.selected {
704                Some(
705                    IconButton::new("delete", IconName::Trash)
706                        .shape(IconButtonShape::Square)
707                        .icon_size(IconSize::XSmall)
708                        .icon_color(Color::Muted)
709                        .tooltip(move |window, cx| {
710                            Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
711                        })
712                        .on_click({
713                            let agent_panel = self.agent_panel.clone();
714
715                            let f: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static> =
716                                match &self.entry {
717                                    HistoryEntry::Thread(thread) => {
718                                        let id = thread.id.clone();
719
720                                        Box::new(move |_event, _window, cx| {
721                                            agent_panel
722                                                .update(cx, |this, cx| {
723                                                    this.delete_thread(&id, cx)
724                                                        .detach_and_log_err(cx);
725                                                })
726                                                .ok();
727                                        })
728                                    }
729                                    HistoryEntry::Context(context) => {
730                                        let path = context.path.clone();
731
732                                        Box::new(move |_event, _window, cx| {
733                                            agent_panel
734                                                .update(cx, |this, cx| {
735                                                    this.delete_context(path.clone(), cx)
736                                                        .detach_and_log_err(cx);
737                                                })
738                                                .ok();
739                                        })
740                                    }
741                                };
742                            f
743                        }),
744                )
745            } else {
746                None
747            })
748            .on_click({
749                let agent_panel = self.agent_panel.clone();
750
751                let f: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static> = match &self.entry
752                {
753                    HistoryEntry::Thread(thread) => {
754                        let id = thread.id.clone();
755                        Box::new(move |_event, window, cx| {
756                            agent_panel
757                                .update(cx, |this, cx| {
758                                    this.open_thread_by_id(&id, window, cx)
759                                        .detach_and_log_err(cx);
760                                })
761                                .ok();
762                        })
763                    }
764                    HistoryEntry::Context(context) => {
765                        let path = context.path.clone();
766                        Box::new(move |_event, window, cx| {
767                            agent_panel
768                                .update(cx, |this, cx| {
769                                    this.open_saved_prompt_editor(path.clone(), window, cx)
770                                        .detach_and_log_err(cx);
771                                })
772                                .ok();
773                        })
774                    }
775                };
776                f
777            })
778    }
779}
780
781#[derive(Clone, Copy)]
782pub enum EntryTimeFormat {
783    DateAndTime,
784    TimeOnly,
785}
786
787impl EntryTimeFormat {
788    fn format_timestamp(
789        &self,
790        agent_panel: &WeakEntity<AgentPanel>,
791        timestamp: i64,
792        cx: &App,
793    ) -> String {
794        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
795        let timezone = agent_panel
796            .read_with(cx, |this, _cx| this.local_timezone())
797            .unwrap_or(UtcOffset::UTC);
798
799        match &self {
800            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
801                timestamp,
802                OffsetDateTime::now_utc(),
803                timezone,
804                time_format::TimestampFormat::EnhancedAbsolute,
805            ),
806            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp),
807        }
808    }
809}
810
811impl From<TimeBucket> for EntryTimeFormat {
812    fn from(bucket: TimeBucket) -> Self {
813        match bucket {
814            TimeBucket::Today => EntryTimeFormat::TimeOnly,
815            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
816            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
817            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
818            TimeBucket::All => EntryTimeFormat::DateAndTime,
819        }
820    }
821}
822
823#[derive(PartialEq, Eq, Clone, Copy, Debug)]
824enum TimeBucket {
825    Today,
826    Yesterday,
827    ThisWeek,
828    PastWeek,
829    All,
830}
831
832impl TimeBucket {
833    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
834        if date == reference {
835            return TimeBucket::Today;
836        }
837
838        if date == reference - TimeDelta::days(1) {
839            return TimeBucket::Yesterday;
840        }
841
842        let week = date.iso_week();
843
844        if reference.iso_week() == week {
845            return TimeBucket::ThisWeek;
846        }
847
848        let last_week = (reference - TimeDelta::days(7)).iso_week();
849
850        if week == last_week {
851            return TimeBucket::PastWeek;
852        }
853
854        TimeBucket::All
855    }
856}
857
858impl Display for TimeBucket {
859    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
860        match self {
861            TimeBucket::Today => write!(f, "Today"),
862            TimeBucket::Yesterday => write!(f, "Yesterday"),
863            TimeBucket::ThisWeek => write!(f, "This Week"),
864            TimeBucket::PastWeek => write!(f, "Past Week"),
865            TimeBucket::All => write!(f, "All"),
866        }
867    }
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873    use chrono::NaiveDate;
874
875    #[test]
876    fn test_time_bucket_from_dates() {
877        let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
878
879        let date = today;
880        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
881
882        let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
883        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
884
885        let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
886        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
887
888        let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
889        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
890
891        let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
892        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
893
894        let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
895        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
896
897        // All: not in this week or last week
898        let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
899        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
900
901        // Test year boundary cases
902        let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
903
904        let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
905        assert_eq!(
906            TimeBucket::from_dates(new_year, date),
907            TimeBucket::Yesterday
908        );
909
910        let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
911        assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
912    }
913}