thread_history.rs

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