threads_archive_view.rs

  1use crate::agent_connection_store::AgentConnectionStore;
  2
  3use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
  4use crate::{Agent, RemoveSelectedThread};
  5
  6use agent::ThreadStore;
  7use agent_client_protocol as acp;
  8use agent_settings::AgentSettings;
  9use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc};
 10use editor::Editor;
 11use fs::Fs;
 12use gpui::{
 13    AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render,
 14    SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px,
 15};
 16use itertools::Itertools as _;
 17use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
 18use project::{AgentId, AgentServerStore};
 19use settings::Settings as _;
 20use theme::ActiveTheme;
 21use ui::ThreadItem;
 22use ui::{
 23    Divider, KeyBinding, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height,
 24};
 25
 26use zed_actions::agents_sidebar::FocusSidebarFilter;
 27use zed_actions::editor::{MoveDown, MoveUp};
 28
 29#[derive(Clone)]
 30enum ArchiveListItem {
 31    BucketSeparator(TimeBucket),
 32    Entry {
 33        thread: ThreadMetadata,
 34        highlight_positions: Vec<usize>,
 35    },
 36}
 37
 38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 39enum TimeBucket {
 40    Today,
 41    Yesterday,
 42    ThisWeek,
 43    PastWeek,
 44    Older,
 45}
 46
 47impl TimeBucket {
 48    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
 49        if date == reference {
 50            return TimeBucket::Today;
 51        }
 52        if date == reference - TimeDelta::days(1) {
 53            return TimeBucket::Yesterday;
 54        }
 55        let week = date.iso_week();
 56        if reference.iso_week() == week {
 57            return TimeBucket::ThisWeek;
 58        }
 59        let last_week = (reference - TimeDelta::days(7)).iso_week();
 60        if week == last_week {
 61            return TimeBucket::PastWeek;
 62        }
 63        TimeBucket::Older
 64    }
 65
 66    fn label(&self) -> &'static str {
 67        match self {
 68            TimeBucket::Today => "Today",
 69            TimeBucket::Yesterday => "Yesterday",
 70            TimeBucket::ThisWeek => "This Week",
 71            TimeBucket::PastWeek => "Past Week",
 72            TimeBucket::Older => "Older",
 73        }
 74    }
 75}
 76
 77fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
 78    let query = query.to_lowercase();
 79    let text_lower = text.to_lowercase();
 80    let mut positions = Vec::new();
 81    let mut query_chars = query.chars().peekable();
 82    for (i, c) in text_lower.chars().enumerate() {
 83        if query_chars.peek() == Some(&c) {
 84            positions.push(i);
 85            query_chars.next();
 86        }
 87    }
 88    if query_chars.peek().is_none() {
 89        Some(positions)
 90    } else {
 91        None
 92    }
 93}
 94
 95pub enum ThreadsArchiveViewEvent {
 96    Close,
 97    Unarchive { thread: ThreadMetadata },
 98}
 99
100impl EventEmitter<ThreadsArchiveViewEvent> for ThreadsArchiveView {}
101
102pub struct ThreadsArchiveView {
103    _history_subscription: Subscription,
104    focus_handle: FocusHandle,
105    list_state: ListState,
106    items: Vec<ArchiveListItem>,
107    selection: Option<usize>,
108    hovered_index: Option<usize>,
109    preserve_selection_on_next_update: bool,
110    filter_editor: Entity<Editor>,
111    _subscriptions: Vec<gpui::Subscription>,
112    _refresh_history_task: Task<()>,
113    agent_connection_store: WeakEntity<AgentConnectionStore>,
114    agent_server_store: WeakEntity<AgentServerStore>,
115}
116
117impl ThreadsArchiveView {
118    pub fn new(
119        agent_connection_store: WeakEntity<AgentConnectionStore>,
120        agent_server_store: WeakEntity<AgentServerStore>,
121        window: &mut Window,
122        cx: &mut Context<Self>,
123    ) -> Self {
124        let focus_handle = cx.focus_handle();
125
126        let filter_editor = cx.new(|cx| {
127            let mut editor = Editor::single_line(window, cx);
128            editor.set_placeholder_text("Search archive…", window, cx);
129            editor
130        });
131
132        let filter_editor_subscription =
133            cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
134                if let editor::EditorEvent::BufferEdited = event {
135                    this.update_items(cx);
136                }
137            });
138
139        let filter_focus_handle = filter_editor.read(cx).focus_handle(cx);
140        cx.on_focus_in(
141            &filter_focus_handle,
142            window,
143            |this: &mut Self, _window, cx| {
144                if this.selection.is_some() {
145                    this.selection = None;
146                    cx.notify();
147                }
148            },
149        )
150        .detach();
151
152        let thread_metadata_store_subscription = cx.observe(
153            &ThreadMetadataStore::global(cx),
154            |this: &mut Self, _, cx| {
155                this.update_items(cx);
156            },
157        );
158
159        cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, _window, cx| {
160            this.selection = None;
161            cx.notify();
162        })
163        .detach();
164
165        let mut this = Self {
166            _history_subscription: Subscription::new(|| {}),
167            focus_handle,
168            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
169            items: Vec::new(),
170            selection: None,
171            hovered_index: None,
172            preserve_selection_on_next_update: false,
173            filter_editor,
174            _subscriptions: vec![
175                filter_editor_subscription,
176                thread_metadata_store_subscription,
177            ],
178            _refresh_history_task: Task::ready(()),
179            agent_connection_store,
180            agent_server_store,
181        };
182
183        this.update_items(cx);
184        this
185    }
186
187    pub fn has_selection(&self) -> bool {
188        self.selection.is_some()
189    }
190
191    pub fn clear_selection(&mut self) {
192        self.selection = None;
193    }
194
195    pub fn focus_filter_editor(&self, window: &mut Window, cx: &mut App) {
196        let handle = self.filter_editor.read(cx).focus_handle(cx);
197        handle.focus(window, cx);
198    }
199
200    fn update_items(&mut self, cx: &mut Context<Self>) {
201        let sessions = ThreadMetadataStore::global(cx)
202            .read(cx)
203            .archived_entries()
204            .sorted_by_cached_key(|t| t.created_at.unwrap_or(t.updated_at))
205            .rev()
206            .cloned()
207            .collect::<Vec<_>>();
208
209        let query = self.filter_editor.read(cx).text(cx).to_lowercase();
210        let today = Local::now().naive_local().date();
211
212        let mut items = Vec::with_capacity(sessions.len() + 5);
213        let mut current_bucket: Option<TimeBucket> = None;
214
215        for session in sessions {
216            let highlight_positions = if !query.is_empty() {
217                match fuzzy_match_positions(&query, &session.title) {
218                    Some(positions) => positions,
219                    None => continue,
220                }
221            } else {
222                Vec::new()
223            };
224
225            let entry_bucket = {
226                let entry_date = session
227                    .created_at
228                    .unwrap_or(session.updated_at)
229                    .with_timezone(&Local)
230                    .naive_local()
231                    .date();
232                TimeBucket::from_dates(today, entry_date)
233            };
234
235            if Some(entry_bucket) != current_bucket {
236                current_bucket = Some(entry_bucket);
237                items.push(ArchiveListItem::BucketSeparator(entry_bucket));
238            }
239
240            items.push(ArchiveListItem::Entry {
241                thread: session,
242                highlight_positions,
243            });
244        }
245
246        let preserve = self.preserve_selection_on_next_update;
247        self.preserve_selection_on_next_update = false;
248
249        let saved_scroll = if preserve {
250            Some(self.list_state.logical_scroll_top())
251        } else {
252            None
253        };
254
255        self.list_state.reset(items.len());
256        self.items = items;
257        self.hovered_index = None;
258
259        if let Some(scroll_top) = saved_scroll {
260            self.list_state.scroll_to(scroll_top);
261
262            if let Some(ix) = self.selection {
263                let next = self.find_next_selectable(ix).or_else(|| {
264                    ix.checked_sub(1)
265                        .and_then(|i| self.find_previous_selectable(i))
266                });
267                self.selection = next;
268                if let Some(next) = next {
269                    self.list_state.scroll_to_reveal_item(next);
270                }
271            }
272        } else {
273            self.selection = None;
274        }
275
276        cx.notify();
277    }
278
279    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
280        self.filter_editor.update(cx, |editor, cx| {
281            editor.set_text("", window, cx);
282        });
283    }
284
285    fn unarchive_thread(
286        &mut self,
287        thread: ThreadMetadata,
288        window: &mut Window,
289        cx: &mut Context<Self>,
290    ) {
291        self.selection = None;
292        self.reset_filter_editor_text(window, cx);
293        cx.emit(ThreadsArchiveViewEvent::Unarchive { thread });
294    }
295
296    fn is_selectable_item(&self, ix: usize) -> bool {
297        matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
298    }
299
300    fn find_next_selectable(&self, start: usize) -> Option<usize> {
301        (start..self.items.len()).find(|&i| self.is_selectable_item(i))
302    }
303
304    fn find_previous_selectable(&self, start: usize) -> Option<usize> {
305        (0..=start).rev().find(|&i| self.is_selectable_item(i))
306    }
307
308    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
309        self.select_next(&SelectNext, window, cx);
310        if self.selection.is_some() {
311            self.focus_handle.focus(window, cx);
312        }
313    }
314
315    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
316        self.select_previous(&SelectPrevious, window, cx);
317        if self.selection.is_some() {
318            self.focus_handle.focus(window, cx);
319        }
320    }
321
322    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
323        let next = match self.selection {
324            Some(ix) => self.find_next_selectable(ix + 1),
325            None => self.find_next_selectable(0),
326        };
327        if let Some(next) = next {
328            self.selection = Some(next);
329            self.list_state.scroll_to_reveal_item(next);
330            cx.notify();
331        }
332    }
333
334    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
335        match self.selection {
336            Some(ix) => {
337                if let Some(prev) = (ix > 0)
338                    .then(|| self.find_previous_selectable(ix - 1))
339                    .flatten()
340                {
341                    self.selection = Some(prev);
342                    self.list_state.scroll_to_reveal_item(prev);
343                } else {
344                    self.selection = None;
345                    self.focus_filter_editor(window, cx);
346                }
347                cx.notify();
348            }
349            None => {
350                let last = self.items.len().saturating_sub(1);
351                if let Some(prev) = self.find_previous_selectable(last) {
352                    self.selection = Some(prev);
353                    self.list_state.scroll_to_reveal_item(prev);
354                    cx.notify();
355                }
356            }
357        }
358    }
359
360    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
361        if let Some(first) = self.find_next_selectable(0) {
362            self.selection = Some(first);
363            self.list_state.scroll_to_reveal_item(first);
364            cx.notify();
365        }
366    }
367
368    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
369        let last = self.items.len().saturating_sub(1);
370        if let Some(last) = self.find_previous_selectable(last) {
371            self.selection = Some(last);
372            self.list_state.scroll_to_reveal_item(last);
373            cx.notify();
374        }
375    }
376
377    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
378        let Some(ix) = self.selection else { return };
379        let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
380            return;
381        };
382
383        if thread.folder_paths.is_empty() {
384            return;
385        }
386
387        self.unarchive_thread(thread.clone(), window, cx);
388    }
389
390    fn render_list_entry(
391        &mut self,
392        ix: usize,
393        _window: &mut Window,
394        cx: &mut Context<Self>,
395    ) -> AnyElement {
396        let Some(item) = self.items.get(ix) else {
397            return div().into_any_element();
398        };
399
400        match item {
401            ArchiveListItem::BucketSeparator(bucket) => div()
402                .w_full()
403                .px_2p5()
404                .pt_3()
405                .pb_1()
406                .child(
407                    Label::new(bucket.label())
408                        .size(LabelSize::Small)
409                        .color(Color::Muted),
410                )
411                .into_any_element(),
412            ArchiveListItem::Entry {
413                thread,
414                highlight_positions,
415            } => {
416                let id = SharedString::from(format!("archive-entry-{}", ix));
417
418                let is_focused = self.selection == Some(ix);
419                let is_hovered = self.hovered_index == Some(ix);
420
421                let focus_handle = self.focus_handle.clone();
422
423                let timestamp =
424                    format_history_entry_timestamp(thread.created_at.unwrap_or(thread.updated_at));
425
426                let icon_from_external_svg = self
427                    .agent_server_store
428                    .upgrade()
429                    .and_then(|store| store.read(cx).agent_icon(&thread.agent_id));
430
431                let icon = if thread.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
432                    IconName::ZedAgent
433                } else {
434                    IconName::Sparkle
435                };
436
437                ThreadItem::new(id, thread.title.clone())
438                    .icon(icon)
439                    .when_some(icon_from_external_svg, |this, svg| {
440                        this.custom_icon_from_external_svg(svg)
441                    })
442                    .timestamp(timestamp)
443                    .highlight_positions(highlight_positions.clone())
444                    .project_paths(thread.folder_paths.paths_owned())
445                    .focused(is_focused)
446                    .hovered(is_hovered)
447                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
448                        if *is_hovered {
449                            this.hovered_index = Some(ix);
450                        } else if this.hovered_index == Some(ix) {
451                            this.hovered_index = None;
452                        }
453                        cx.notify();
454                    }))
455                    .action_slot(
456                        IconButton::new("delete-thread", IconName::Trash)
457                            .style(ButtonStyle::Filled)
458                            .icon_size(IconSize::Small)
459                            .icon_color(Color::Muted)
460                            .tooltip({
461                                move |_window, cx| {
462                                    Tooltip::for_action_in(
463                                        "Delete Thread",
464                                        &RemoveSelectedThread,
465                                        &focus_handle,
466                                        cx,
467                                    )
468                                }
469                            })
470                            .on_click({
471                                let agent = thread.agent_id.clone();
472                                let session_id = thread.session_id.clone();
473                                cx.listener(move |this, _, _, cx| {
474                                    this.delete_thread(session_id.clone(), agent.clone(), cx);
475                                    cx.stop_propagation();
476                                })
477                            }),
478                    )
479                    .tooltip(move |_, cx| Tooltip::for_action("Restore Thread", &menu::Confirm, cx))
480                    .on_click({
481                        let thread = thread.clone();
482                        cx.listener(move |this, _, window, cx| {
483                            this.unarchive_thread(thread.clone(), window, cx);
484                        })
485                    })
486                    .into_any_element()
487            }
488        }
489    }
490
491    fn remove_selected_thread(
492        &mut self,
493        _: &RemoveSelectedThread,
494        _window: &mut Window,
495        cx: &mut Context<Self>,
496    ) {
497        let Some(ix) = self.selection else { return };
498        let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
499            return;
500        };
501
502        self.preserve_selection_on_next_update = true;
503        self.delete_thread(thread.session_id.clone(), thread.agent_id.clone(), cx);
504    }
505
506    fn delete_thread(
507        &mut self,
508        session_id: acp::SessionId,
509        agent: AgentId,
510        cx: &mut Context<Self>,
511    ) {
512        ThreadMetadataStore::global(cx)
513            .update(cx, |store, cx| store.delete(session_id.clone(), cx));
514
515        let agent = Agent::from(agent);
516
517        let Some(agent_connection_store) = self.agent_connection_store.upgrade() else {
518            return;
519        };
520        let fs = <dyn Fs>::global(cx);
521
522        let task = agent_connection_store.update(cx, |store, cx| {
523            store
524                .request_connection(agent.clone(), agent.server(fs, ThreadStore::global(cx)), cx)
525                .read(cx)
526                .wait_for_connection()
527        });
528        cx.spawn(async move |_this, cx| {
529            let state = task.await?;
530            let task = cx.update(|cx| {
531                if let Some(list) = state.connection.session_list(cx) {
532                    list.delete_session(&session_id, cx)
533                } else {
534                    Task::ready(Ok(()))
535                }
536            });
537            task.await
538        })
539        .detach_and_log_err(cx);
540    }
541
542    fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
543        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
544        let sidebar_on_left = matches!(
545            AgentSettings::get_global(cx).sidebar_side(),
546            settings::SidebarSide::Left
547        );
548        let traffic_lights =
549            cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left;
550        let header_height = platform_title_bar_height(window);
551        let show_focus_keybinding =
552            self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window);
553
554        h_flex()
555            .h(header_height)
556            .mt_px()
557            .pb_px()
558            .map(|this| {
559                if traffic_lights {
560                    this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
561                } else {
562                    this.pl_1p5()
563                }
564            })
565            .pr_1p5()
566            .gap_1()
567            .justify_between()
568            .border_b_1()
569            .border_color(cx.theme().colors().border)
570            .when(traffic_lights, |this| {
571                this.child(Divider::vertical().color(ui::DividerColor::Border))
572            })
573            .child(
574                h_flex()
575                    .ml_1()
576                    .min_w_0()
577                    .w_full()
578                    .gap_1()
579                    .child(
580                        Icon::new(IconName::MagnifyingGlass)
581                            .size(IconSize::Small)
582                            .color(Color::Muted),
583                    )
584                    .child(self.filter_editor.clone()),
585            )
586            .when(show_focus_keybinding, |this| {
587                this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
588            })
589            .when(has_query, |this| {
590                this.child(
591                    IconButton::new("clear-filter", IconName::Close)
592                        .icon_size(IconSize::Small)
593                        .tooltip(Tooltip::text("Clear Search"))
594                        .on_click(cx.listener(|this, _, window, cx| {
595                            this.reset_filter_editor_text(window, cx);
596                            this.update_items(cx);
597                        })),
598                )
599            })
600    }
601}
602
603pub fn format_history_entry_timestamp(entry_time: DateTime<Utc>) -> String {
604    let now = Utc::now();
605    let duration = now.signed_duration_since(entry_time);
606
607    let minutes = duration.num_minutes();
608    let hours = duration.num_hours();
609    let days = duration.num_days();
610    let weeks = days / 7;
611    let months = days / 30;
612
613    if minutes < 60 {
614        format!("{}m", minutes.max(1))
615    } else if hours < 24 {
616        format!("{}h", hours.max(1))
617    } else if days < 7 {
618        format!("{}d", days.max(1))
619    } else if weeks < 4 {
620        format!("{}w", weeks.max(1))
621    } else {
622        format!("{}mo", months.max(1))
623    }
624}
625
626impl Focusable for ThreadsArchiveView {
627    fn focus_handle(&self, _cx: &App) -> FocusHandle {
628        self.focus_handle.clone()
629    }
630}
631
632impl Render for ThreadsArchiveView {
633    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
634        let is_empty = self.items.is_empty();
635        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
636
637        let content = if is_empty {
638            let message = if has_query {
639                "No threads match your search."
640            } else {
641                "No archived or hidden threads yet."
642            };
643
644            v_flex()
645                .flex_1()
646                .justify_center()
647                .items_center()
648                .child(
649                    Label::new(message)
650                        .size(LabelSize::Small)
651                        .color(Color::Muted),
652                )
653                .into_any_element()
654        } else {
655            v_flex()
656                .flex_1()
657                .overflow_hidden()
658                .child(
659                    list(
660                        self.list_state.clone(),
661                        cx.processor(Self::render_list_entry),
662                    )
663                    .flex_1()
664                    .size_full(),
665                )
666                .vertical_scrollbar_for(&self.list_state, window, cx)
667                .into_any_element()
668        };
669
670        v_flex()
671            .key_context("ThreadsArchiveView")
672            .track_focus(&self.focus_handle)
673            .on_action(cx.listener(Self::select_next))
674            .on_action(cx.listener(Self::select_previous))
675            .on_action(cx.listener(Self::editor_move_down))
676            .on_action(cx.listener(Self::editor_move_up))
677            .on_action(cx.listener(Self::select_first))
678            .on_action(cx.listener(Self::select_last))
679            .on_action(cx.listener(Self::confirm))
680            .on_action(cx.listener(Self::remove_selected_thread))
681            .size_full()
682            .child(self.render_header(window, cx))
683            .child(content)
684    }
685}