thread_history.rs

  1use crate::acp::AcpThreadView;
  2use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
  3use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest};
  4use agent_client_protocol as acp;
  5use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
  6use editor::{Editor, EditorEvent};
  7use fuzzy::StringMatchCandidate;
  8use gpui::{
  9    App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
 10    UniformListScrollHandle, WeakEntity, Window, uniform_list,
 11};
 12use std::{fmt::Display, ops::Range, rc::Rc};
 13use text::Bias;
 14use time::{OffsetDateTime, UtcOffset};
 15use ui::{
 16    ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip,
 17    WithScrollbar, prelude::*,
 18};
 19
 20const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
 21
 22fn thread_title(entry: &AgentSessionInfo) -> &SharedString {
 23    entry
 24        .title
 25        .as_ref()
 26        .filter(|title| !title.is_empty())
 27        .unwrap_or(DEFAULT_TITLE)
 28}
 29
 30pub struct AcpThreadHistory {
 31    session_list: Option<Rc<dyn AgentSessionList>>,
 32    sessions: Vec<AgentSessionInfo>,
 33    scroll_handle: UniformListScrollHandle,
 34    selected_index: usize,
 35    hovered_index: Option<usize>,
 36    search_editor: Entity<Editor>,
 37    search_query: SharedString,
 38    visible_items: Vec<ListItemType>,
 39    local_timezone: UtcOffset,
 40    confirming_delete_history: bool,
 41    _update_task: Task<()>,
 42    _watch_task: Option<Task<()>>,
 43    _subscriptions: Vec<gpui::Subscription>,
 44}
 45
 46enum ListItemType {
 47    BucketSeparator(TimeBucket),
 48    Entry {
 49        entry: AgentSessionInfo,
 50        format: EntryTimeFormat,
 51    },
 52    SearchResult {
 53        entry: AgentSessionInfo,
 54        positions: Vec<usize>,
 55    },
 56}
 57
 58impl ListItemType {
 59    fn history_entry(&self) -> Option<&AgentSessionInfo> {
 60        match self {
 61            ListItemType::Entry { entry, .. } => Some(entry),
 62            ListItemType::SearchResult { entry, .. } => Some(entry),
 63            _ => None,
 64        }
 65    }
 66}
 67
 68pub enum ThreadHistoryEvent {
 69    Open(AgentSessionInfo),
 70}
 71
 72impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
 73
 74impl AcpThreadHistory {
 75    pub(crate) fn new(
 76        session_list: Option<Rc<dyn AgentSessionList>>,
 77        window: &mut Window,
 78        cx: &mut Context<Self>,
 79    ) -> Self {
 80        let search_editor = cx.new(|cx| {
 81            let mut editor = Editor::single_line(window, cx);
 82            editor.set_placeholder_text("Search threads...", window, cx);
 83            editor
 84        });
 85
 86        let search_editor_subscription =
 87            cx.subscribe(&search_editor, |this, search_editor, event, cx| {
 88                if let EditorEvent::BufferEdited = event {
 89                    let query = search_editor.read(cx).text(cx);
 90                    if this.search_query != query {
 91                        this.search_query = query.into();
 92                        this.update_visible_items(false, cx);
 93                    }
 94                }
 95            });
 96
 97        let scroll_handle = UniformListScrollHandle::default();
 98
 99        let mut this = Self {
100            session_list: None,
101            sessions: Vec::new(),
102            scroll_handle,
103            selected_index: 0,
104            hovered_index: None,
105            visible_items: Default::default(),
106            search_editor,
107            local_timezone: UtcOffset::from_whole_seconds(
108                chrono::Local::now().offset().local_minus_utc(),
109            )
110            .unwrap(),
111            search_query: SharedString::default(),
112            confirming_delete_history: false,
113            _subscriptions: vec![search_editor_subscription],
114            _update_task: Task::ready(()),
115            _watch_task: None,
116        };
117        this.set_session_list(session_list, cx);
118        this
119    }
120
121    fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
122        let entries = self.sessions.clone();
123        let new_list_items = if self.search_query.is_empty() {
124            self.add_list_separators(entries, cx)
125        } else {
126            self.filter_search_results(entries, cx)
127        };
128        let selected_history_entry = if preserve_selected_item {
129            self.selected_history_entry().cloned()
130        } else {
131            None
132        };
133
134        self._update_task = cx.spawn(async move |this, cx| {
135            let new_visible_items = new_list_items.await;
136            this.update(cx, |this, cx| {
137                let new_selected_index = if let Some(history_entry) = selected_history_entry {
138                    new_visible_items
139                        .iter()
140                        .position(|visible_entry| {
141                            visible_entry
142                                .history_entry()
143                                .is_some_and(|entry| entry.session_id == history_entry.session_id)
144                        })
145                        .unwrap_or(0)
146                } else {
147                    0
148                };
149
150                this.visible_items = new_visible_items;
151                this.set_selected_index(new_selected_index, Bias::Right, cx);
152                cx.notify();
153            })
154            .ok();
155        });
156    }
157
158    pub(crate) fn set_session_list(
159        &mut self,
160        session_list: Option<Rc<dyn AgentSessionList>>,
161        cx: &mut Context<Self>,
162    ) {
163        if let (Some(current), Some(next)) = (&self.session_list, &session_list)
164            && Rc::ptr_eq(current, next)
165        {
166            return;
167        }
168
169        self.session_list = session_list;
170        self.sessions.clear();
171        self.visible_items.clear();
172        self.selected_index = 0;
173        self.refresh_sessions(false, cx);
174
175        self._watch_task = self.session_list.as_ref().and_then(|session_list| {
176            let mut rx = session_list.watch(cx)?;
177            Some(cx.spawn(async move |this, cx| {
178                while let Ok(()) = rx.recv().await {
179                    this.update(cx, |this, cx| {
180                        this.refresh_sessions(true, cx);
181                    })
182                    .ok();
183                }
184            }))
185        });
186    }
187
188    fn refresh_sessions(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
189        let Some(session_list) = self.session_list.clone() else {
190            self.update_visible_items(preserve_selected_item, cx);
191            return;
192        };
193
194        self._update_task = cx.spawn(async move |this, cx| {
195            let mut cursor: Option<String> = None;
196            let mut is_first_page = true;
197
198            loop {
199                let request = AgentSessionListRequest {
200                    cursor: cursor.clone(),
201                    ..Default::default()
202                };
203                let task = cx.update(|cx| session_list.list_sessions(request, cx));
204                let response = match task.await {
205                    Ok(response) => response,
206                    Err(error) => {
207                        log::error!("Failed to load session history: {error:#}");
208                        return;
209                    }
210                };
211
212                let acp_thread::AgentSessionListResponse {
213                    sessions: page_sessions,
214                    next_cursor,
215                    ..
216                } = response;
217
218                this.update(cx, |this, cx| {
219                    if is_first_page {
220                        this.sessions = page_sessions;
221                    } else {
222                        this.sessions.extend(page_sessions);
223                    }
224                    this.update_visible_items(preserve_selected_item, cx);
225                })
226                .ok();
227
228                is_first_page = false;
229                match next_cursor {
230                    Some(next_cursor) => {
231                        if cursor.as_ref() == Some(&next_cursor) {
232                            log::warn!(
233                                "Session list pagination returned the same cursor; stopping to avoid a loop."
234                            );
235                            break;
236                        }
237                        cursor = Some(next_cursor);
238                    }
239                    None => break,
240                }
241            }
242        });
243    }
244
245    pub(crate) fn is_empty(&self) -> bool {
246        self.sessions.is_empty()
247    }
248
249    pub(crate) fn session_for_id(&self, session_id: &acp::SessionId) -> Option<AgentSessionInfo> {
250        self.sessions
251            .iter()
252            .find(|entry| &entry.session_id == session_id)
253            .cloned()
254    }
255
256    pub(crate) fn sessions(&self) -> &[AgentSessionInfo] {
257        &self.sessions
258    }
259
260    fn add_list_separators(
261        &self,
262        entries: Vec<AgentSessionInfo>,
263        cx: &App,
264    ) -> Task<Vec<ListItemType>> {
265        cx.background_spawn(async move {
266            let mut items = Vec::with_capacity(entries.len() + 1);
267            let mut bucket = None;
268            let today = Local::now().naive_local().date();
269
270            for entry in entries.into_iter() {
271                let entry_bucket = entry
272                    .updated_at
273                    .map(|timestamp| {
274                        let entry_date = timestamp.with_timezone(&Local).naive_local().date();
275                        TimeBucket::from_dates(today, entry_date)
276                    })
277                    .unwrap_or(TimeBucket::All);
278
279                if Some(entry_bucket) != bucket {
280                    bucket = Some(entry_bucket);
281                    items.push(ListItemType::BucketSeparator(entry_bucket));
282                }
283
284                items.push(ListItemType::Entry {
285                    entry,
286                    format: entry_bucket.into(),
287                });
288            }
289            items
290        })
291    }
292
293    fn filter_search_results(
294        &self,
295        entries: Vec<AgentSessionInfo>,
296        cx: &App,
297    ) -> Task<Vec<ListItemType>> {
298        let query = self.search_query.clone();
299        cx.background_spawn({
300            let executor = cx.background_executor().clone();
301            async move {
302                let mut candidates = Vec::with_capacity(entries.len());
303
304                for (idx, entry) in entries.iter().enumerate() {
305                    candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
306                }
307
308                const MAX_MATCHES: usize = 100;
309
310                let matches = fuzzy::match_strings(
311                    &candidates,
312                    &query,
313                    false,
314                    true,
315                    MAX_MATCHES,
316                    &Default::default(),
317                    executor,
318                )
319                .await;
320
321                matches
322                    .into_iter()
323                    .map(|search_match| ListItemType::SearchResult {
324                        entry: entries[search_match.candidate_id].clone(),
325                        positions: search_match.positions,
326                    })
327                    .collect()
328            }
329        })
330    }
331
332    fn search_produced_no_matches(&self) -> bool {
333        self.visible_items.is_empty() && !self.search_query.is_empty()
334    }
335
336    fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
337        self.get_history_entry(self.selected_index)
338    }
339
340    fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
341        self.visible_items.get(visible_items_ix)?.history_entry()
342    }
343
344    fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
345        if self.visible_items.len() == 0 {
346            self.selected_index = 0;
347            return;
348        }
349        while matches!(
350            self.visible_items.get(index),
351            None | Some(ListItemType::BucketSeparator(..))
352        ) {
353            index = match bias {
354                Bias::Left => {
355                    if index == 0 {
356                        self.visible_items.len() - 1
357                    } else {
358                        index - 1
359                    }
360                }
361                Bias::Right => {
362                    if index >= self.visible_items.len() - 1 {
363                        0
364                    } else {
365                        index + 1
366                    }
367                }
368            };
369        }
370        self.selected_index = index;
371        self.scroll_handle
372            .scroll_to_item(index, ScrollStrategy::Top);
373        cx.notify()
374    }
375
376    pub fn select_previous(
377        &mut self,
378        _: &menu::SelectPrevious,
379        _window: &mut Window,
380        cx: &mut Context<Self>,
381    ) {
382        if self.selected_index == 0 {
383            self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
384        } else {
385            self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
386        }
387    }
388
389    pub fn select_next(
390        &mut self,
391        _: &menu::SelectNext,
392        _window: &mut Window,
393        cx: &mut Context<Self>,
394    ) {
395        if self.selected_index == self.visible_items.len() - 1 {
396            self.set_selected_index(0, Bias::Right, cx);
397        } else {
398            self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
399        }
400    }
401
402    fn select_first(
403        &mut self,
404        _: &menu::SelectFirst,
405        _window: &mut Window,
406        cx: &mut Context<Self>,
407    ) {
408        self.set_selected_index(0, Bias::Right, cx);
409    }
410
411    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
412        self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
413    }
414
415    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
416        self.confirm_entry(self.selected_index, cx);
417    }
418
419    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
420        let Some(entry) = self.get_history_entry(ix) else {
421            return;
422        };
423        cx.emit(ThreadHistoryEvent::Open(entry.clone()));
424    }
425
426    fn remove_selected_thread(
427        &mut self,
428        _: &RemoveSelectedThread,
429        _window: &mut Window,
430        cx: &mut Context<Self>,
431    ) {
432        self.remove_thread(self.selected_index, cx)
433    }
434
435    fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
436        let Some(entry) = self.get_history_entry(visible_item_ix) else {
437            return;
438        };
439        let Some(session_list) = self.session_list.as_ref() else {
440            return;
441        };
442        let task = session_list.delete_session(&entry.session_id, cx);
443        task.detach_and_log_err(cx);
444    }
445
446    fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
447        if let Some(session_list) = self.session_list.as_ref() {
448            session_list.delete_sessions(cx).detach_and_log_err(cx);
449        }
450        self.confirming_delete_history = false;
451        cx.notify();
452    }
453
454    fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
455        self.confirming_delete_history = true;
456        cx.notify();
457    }
458
459    fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
460        self.confirming_delete_history = false;
461        cx.notify();
462    }
463
464    fn render_list_items(
465        &mut self,
466        range: Range<usize>,
467        _window: &mut Window,
468        cx: &mut Context<Self>,
469    ) -> Vec<AnyElement> {
470        self.visible_items
471            .get(range.clone())
472            .into_iter()
473            .flatten()
474            .enumerate()
475            .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
476            .collect()
477    }
478
479    fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
480        match item {
481            ListItemType::Entry { entry, format } => self
482                .render_history_entry(entry, *format, ix, Vec::default(), cx)
483                .into_any(),
484            ListItemType::SearchResult { entry, positions } => self.render_history_entry(
485                entry,
486                EntryTimeFormat::DateAndTime,
487                ix,
488                positions.clone(),
489                cx,
490            ),
491            ListItemType::BucketSeparator(bucket) => div()
492                .px(DynamicSpacing::Base06.rems(cx))
493                .pt_2()
494                .pb_1()
495                .child(
496                    Label::new(bucket.to_string())
497                        .size(LabelSize::XSmall)
498                        .color(Color::Muted),
499                )
500                .into_any_element(),
501        }
502    }
503
504    fn render_history_entry(
505        &self,
506        entry: &AgentSessionInfo,
507        format: EntryTimeFormat,
508        ix: usize,
509        highlight_positions: Vec<usize>,
510        cx: &Context<Self>,
511    ) -> AnyElement {
512        let selected = ix == self.selected_index;
513        let hovered = Some(ix) == self.hovered_index;
514        let entry_time = entry.updated_at;
515        let display_text = match (format, entry_time) {
516            (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
517                let now = Utc::now();
518                let duration = now.signed_duration_since(entry_time);
519                let days = duration.num_days();
520
521                format!("{}d", days)
522            }
523            (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
524                format.format_timestamp(entry_time.timestamp(), self.local_timezone)
525            }
526            (_, None) => "".to_string(),
527        };
528
529        let title = thread_title(entry).clone();
530        let full_date = entry_time
531            .map(|time| {
532                EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
533            })
534            .unwrap_or_else(|| "Unknown".to_string());
535
536        h_flex()
537            .w_full()
538            .pb_1()
539            .child(
540                ListItem::new(ix)
541                    .rounded()
542                    .toggle_state(selected)
543                    .spacing(ListItemSpacing::Sparse)
544                    .start_slot(
545                        h_flex()
546                            .w_full()
547                            .gap_2()
548                            .justify_between()
549                            .child(
550                                HighlightedLabel::new(thread_title(entry), highlight_positions)
551                                    .size(LabelSize::Small)
552                                    .truncate(),
553                            )
554                            .child(
555                                Label::new(display_text)
556                                    .color(Color::Muted)
557                                    .size(LabelSize::XSmall),
558                            ),
559                    )
560                    .tooltip(move |_, cx| {
561                        Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
562                    })
563                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
564                        if *is_hovered {
565                            this.hovered_index = Some(ix);
566                        } else if this.hovered_index == Some(ix) {
567                            this.hovered_index = None;
568                        }
569
570                        cx.notify();
571                    }))
572                    .end_slot::<IconButton>(if hovered {
573                        Some(
574                            IconButton::new("delete", IconName::Trash)
575                                .shape(IconButtonShape::Square)
576                                .icon_size(IconSize::XSmall)
577                                .icon_color(Color::Muted)
578                                .tooltip(move |_window, cx| {
579                                    Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
580                                })
581                                .on_click(cx.listener(move |this, _, _, cx| {
582                                    this.remove_thread(ix, cx);
583                                    cx.stop_propagation()
584                                })),
585                        )
586                    } else {
587                        None
588                    })
589                    .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
590            )
591            .into_any_element()
592    }
593}
594
595impl Focusable for AcpThreadHistory {
596    fn focus_handle(&self, cx: &App) -> FocusHandle {
597        self.search_editor.focus_handle(cx)
598    }
599}
600
601impl Render for AcpThreadHistory {
602    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
603        let has_no_history = self.is_empty();
604
605        v_flex()
606            .key_context("ThreadHistory")
607            .size_full()
608            .bg(cx.theme().colors().panel_background)
609            .on_action(cx.listener(Self::select_previous))
610            .on_action(cx.listener(Self::select_next))
611            .on_action(cx.listener(Self::select_first))
612            .on_action(cx.listener(Self::select_last))
613            .on_action(cx.listener(Self::confirm))
614            .on_action(cx.listener(Self::remove_selected_thread))
615            .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
616                this.remove_history(window, cx);
617            }))
618            .child(
619                h_flex()
620                    .h(Tab::container_height(cx))
621                    .w_full()
622                    .py_1()
623                    .px_2()
624                    .gap_2()
625                    .justify_between()
626                    .border_b_1()
627                    .border_color(cx.theme().colors().border)
628                    .child(
629                        Icon::new(IconName::MagnifyingGlass)
630                            .color(Color::Muted)
631                            .size(IconSize::Small),
632                    )
633                    .child(self.search_editor.clone()),
634            )
635            .child({
636                let view = v_flex()
637                    .id("list-container")
638                    .relative()
639                    .overflow_hidden()
640                    .flex_grow();
641
642                if has_no_history {
643                    view.justify_center().items_center().child(
644                        Label::new("You don't have any past threads yet.")
645                            .size(LabelSize::Small)
646                            .color(Color::Muted),
647                    )
648                } else if self.search_produced_no_matches() {
649                    view.justify_center()
650                        .items_center()
651                        .child(Label::new("No threads match your search.").size(LabelSize::Small))
652                } else {
653                    view.child(
654                        uniform_list(
655                            "thread-history",
656                            self.visible_items.len(),
657                            cx.processor(|this, range: Range<usize>, window, cx| {
658                                this.render_list_items(range, window, cx)
659                            }),
660                        )
661                        .p_1()
662                        .pr_4()
663                        .track_scroll(&self.scroll_handle)
664                        .flex_grow(),
665                    )
666                    .vertical_scrollbar_for(&self.scroll_handle, window, cx)
667                }
668            })
669            .when(!has_no_history, |this| {
670                this.child(
671                    h_flex()
672                        .p_2()
673                        .border_t_1()
674                        .border_color(cx.theme().colors().border_variant)
675                        .when(!self.confirming_delete_history, |this| {
676                            this.child(
677                                Button::new("delete_history", "Delete All History")
678                                    .full_width()
679                                    .style(ButtonStyle::Outlined)
680                                    .label_size(LabelSize::Small)
681                                    .on_click(cx.listener(|this, _, window, cx| {
682                                        this.prompt_delete_history(window, cx);
683                                    })),
684                            )
685                        })
686                        .when(self.confirming_delete_history, |this| {
687                            this.w_full()
688                                .gap_2()
689                                .flex_wrap()
690                                .justify_between()
691                                .child(
692                                    h_flex()
693                                        .flex_wrap()
694                                        .gap_1()
695                                        .child(
696                                            Label::new("Delete all threads?")
697                                                .size(LabelSize::Small),
698                                        )
699                                        .child(
700                                            Label::new("You won't be able to recover them later.")
701                                                .size(LabelSize::Small)
702                                                .color(Color::Muted),
703                                        ),
704                                )
705                                .child(
706                                    h_flex()
707                                        .gap_1()
708                                        .child(
709                                            Button::new("cancel_delete", "Cancel")
710                                                .label_size(LabelSize::Small)
711                                                .on_click(cx.listener(|this, _, window, cx| {
712                                                    this.cancel_delete_history(window, cx);
713                                                })),
714                                        )
715                                        .child(
716                                            Button::new("confirm_delete", "Delete")
717                                                .style(ButtonStyle::Tinted(ui::TintColor::Error))
718                                                .color(Color::Error)
719                                                .label_size(LabelSize::Small)
720                                                .on_click(cx.listener(|_, _, window, cx| {
721                                                    window.dispatch_action(
722                                                        Box::new(RemoveHistory),
723                                                        cx,
724                                                    );
725                                                })),
726                                        ),
727                                )
728                        }),
729                )
730            })
731    }
732}
733
734#[derive(IntoElement)]
735pub struct AcpHistoryEntryElement {
736    entry: AgentSessionInfo,
737    thread_view: WeakEntity<AcpThreadView>,
738    selected: bool,
739    hovered: bool,
740    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
741}
742
743impl AcpHistoryEntryElement {
744    pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<AcpThreadView>) -> Self {
745        Self {
746            entry,
747            thread_view,
748            selected: false,
749            hovered: false,
750            on_hover: Box::new(|_, _, _| {}),
751        }
752    }
753
754    pub fn hovered(mut self, hovered: bool) -> Self {
755        self.hovered = hovered;
756        self
757    }
758
759    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
760        self.on_hover = Box::new(on_hover);
761        self
762    }
763}
764
765impl RenderOnce for AcpHistoryEntryElement {
766    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
767        let id = ElementId::Name(self.entry.session_id.0.clone().into());
768        let title = thread_title(&self.entry).clone();
769        let formatted_time = self
770            .entry
771            .updated_at
772            .map(|timestamp| {
773                let now = chrono::Utc::now();
774                let duration = now.signed_duration_since(timestamp);
775
776                if duration.num_days() > 0 {
777                    format!("{}d", duration.num_days())
778                } else if duration.num_hours() > 0 {
779                    format!("{}h ago", duration.num_hours())
780                } else if duration.num_minutes() > 0 {
781                    format!("{}m ago", duration.num_minutes())
782                } else {
783                    "Just now".to_string()
784                }
785            })
786            .unwrap_or_else(|| "Unknown".to_string());
787
788        ListItem::new(id)
789            .rounded()
790            .toggle_state(self.selected)
791            .spacing(ListItemSpacing::Sparse)
792            .start_slot(
793                h_flex()
794                    .w_full()
795                    .gap_2()
796                    .justify_between()
797                    .child(Label::new(title).size(LabelSize::Small).truncate())
798                    .child(
799                        Label::new(formatted_time)
800                            .color(Color::Muted)
801                            .size(LabelSize::XSmall),
802                    ),
803            )
804            .on_hover(self.on_hover)
805            .end_slot::<IconButton>(if self.hovered || self.selected {
806                Some(
807                    IconButton::new("delete", IconName::Trash)
808                        .shape(IconButtonShape::Square)
809                        .icon_size(IconSize::XSmall)
810                        .icon_color(Color::Muted)
811                        .tooltip(move |_window, cx| {
812                            Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
813                        })
814                        .on_click({
815                            let thread_view = self.thread_view.clone();
816                            let entry = self.entry.clone();
817
818                            move |_event, _window, cx| {
819                                if let Some(thread_view) = thread_view.upgrade() {
820                                    thread_view.update(cx, |thread_view, cx| {
821                                        thread_view.delete_history_entry(entry.clone(), cx);
822                                    });
823                                }
824                            }
825                        }),
826                )
827            } else {
828                None
829            })
830            .on_click({
831                let thread_view = self.thread_view.clone();
832                let entry = self.entry;
833
834                move |_event, window, cx| {
835                    if let Some(workspace) = thread_view
836                        .upgrade()
837                        .and_then(|view| view.read(cx).workspace().upgrade())
838                    {
839                        if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
840                            panel.update(cx, |panel, cx| {
841                                panel.load_agent_thread(entry.clone(), window, cx);
842                            });
843                        }
844                    }
845                }
846            })
847    }
848}
849
850#[derive(Clone, Copy)]
851pub enum EntryTimeFormat {
852    DateAndTime,
853    TimeOnly,
854}
855
856impl EntryTimeFormat {
857    fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
858        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
859
860        match self {
861            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
862                timestamp,
863                OffsetDateTime::now_utc(),
864                timezone,
865                time_format::TimestampFormat::EnhancedAbsolute,
866            ),
867            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
868        }
869    }
870}
871
872impl From<TimeBucket> for EntryTimeFormat {
873    fn from(bucket: TimeBucket) -> Self {
874        match bucket {
875            TimeBucket::Today => EntryTimeFormat::TimeOnly,
876            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
877            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
878            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
879            TimeBucket::All => EntryTimeFormat::DateAndTime,
880        }
881    }
882}
883
884#[derive(PartialEq, Eq, Clone, Copy, Debug)]
885enum TimeBucket {
886    Today,
887    Yesterday,
888    ThisWeek,
889    PastWeek,
890    All,
891}
892
893impl TimeBucket {
894    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
895        if date == reference {
896            return TimeBucket::Today;
897        }
898
899        if date == reference - TimeDelta::days(1) {
900            return TimeBucket::Yesterday;
901        }
902
903        let week = date.iso_week();
904
905        if reference.iso_week() == week {
906            return TimeBucket::ThisWeek;
907        }
908
909        let last_week = (reference - TimeDelta::days(7)).iso_week();
910
911        if week == last_week {
912            return TimeBucket::PastWeek;
913        }
914
915        TimeBucket::All
916    }
917}
918
919impl Display for TimeBucket {
920    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
921        match self {
922            TimeBucket::Today => write!(f, "Today"),
923            TimeBucket::Yesterday => write!(f, "Yesterday"),
924            TimeBucket::ThisWeek => write!(f, "This Week"),
925            TimeBucket::PastWeek => write!(f, "Past Week"),
926            TimeBucket::All => write!(f, "All"),
927        }
928    }
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use chrono::NaiveDate;
935
936    #[test]
937    fn test_time_bucket_from_dates() {
938        let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
939
940        let date = today;
941        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
942
943        let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
944        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
945
946        let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
947        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
948
949        let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
950        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
951
952        let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
953        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
954
955        let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
956        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
957
958        // All: not in this week or last week
959        let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
960        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
961
962        // Test year boundary cases
963        let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
964
965        let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
966        assert_eq!(
967            TimeBucket::from_dates(new_year, date),
968            TimeBucket::Yesterday
969        );
970
971        let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
972        assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
973    }
974}