threads_archive_view.rs

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