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            .collect::<Vec<_>>();
216
217        let query = self.filter_editor.read(cx).text(cx).to_lowercase();
218        let today = Local::now().naive_local().date();
219
220        let mut items = Vec::with_capacity(sessions.len() + 5);
221        let mut current_bucket: Option<TimeBucket> = None;
222
223        for session in sessions {
224            let highlight_positions = if !query.is_empty() {
225                match fuzzy_match_positions(&query, &session.title) {
226                    Some(positions) => positions,
227                    None => continue,
228                }
229            } else {
230                Vec::new()
231            };
232
233            let entry_bucket = {
234                let entry_date = session
235                    .created_at
236                    .unwrap_or(session.updated_at)
237                    .with_timezone(&Local)
238                    .naive_local()
239                    .date();
240                TimeBucket::from_dates(today, entry_date)
241            };
242
243            if Some(entry_bucket) != current_bucket {
244                current_bucket = Some(entry_bucket);
245                items.push(ArchiveListItem::BucketSeparator(entry_bucket));
246            }
247
248            items.push(ArchiveListItem::Entry {
249                thread: session,
250                highlight_positions,
251            });
252        }
253
254        self.list_state.reset(items.len());
255        self.items = items;
256        self.selection = None;
257        self.hovered_index = None;
258        cx.notify();
259    }
260
261    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
262        self.filter_editor.update(cx, |editor, cx| {
263            editor.set_text("", window, cx);
264        });
265    }
266
267    fn unarchive_thread(
268        &mut self,
269        thread: ThreadMetadata,
270        window: &mut Window,
271        cx: &mut Context<Self>,
272    ) {
273        self.selection = None;
274        self.reset_filter_editor_text(window, cx);
275        cx.emit(ThreadsArchiveViewEvent::Unarchive { thread });
276    }
277
278    fn is_selectable_item(&self, ix: usize) -> bool {
279        matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
280    }
281
282    fn find_next_selectable(&self, start: usize) -> Option<usize> {
283        (start..self.items.len()).find(|&i| self.is_selectable_item(i))
284    }
285
286    fn find_previous_selectable(&self, start: usize) -> Option<usize> {
287        (0..=start).rev().find(|&i| self.is_selectable_item(i))
288    }
289
290    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
291        self.select_next(&SelectNext, window, cx);
292        if self.selection.is_some() {
293            self.focus_handle.focus(window, cx);
294        }
295    }
296
297    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
298        self.select_previous(&SelectPrevious, window, cx);
299        if self.selection.is_some() {
300            self.focus_handle.focus(window, cx);
301        }
302    }
303
304    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
305        let next = match self.selection {
306            Some(ix) => self.find_next_selectable(ix + 1),
307            None => self.find_next_selectable(0),
308        };
309        if let Some(next) = next {
310            self.selection = Some(next);
311            self.list_state.scroll_to_reveal_item(next);
312            cx.notify();
313        }
314    }
315
316    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
317        match self.selection {
318            Some(ix) => {
319                if let Some(prev) = (ix > 0)
320                    .then(|| self.find_previous_selectable(ix - 1))
321                    .flatten()
322                {
323                    self.selection = Some(prev);
324                    self.list_state.scroll_to_reveal_item(prev);
325                } else {
326                    self.selection = None;
327                    self.focus_filter_editor(window, cx);
328                }
329                cx.notify();
330            }
331            None => {
332                let last = self.items.len().saturating_sub(1);
333                if let Some(prev) = self.find_previous_selectable(last) {
334                    self.selection = Some(prev);
335                    self.list_state.scroll_to_reveal_item(prev);
336                    cx.notify();
337                }
338            }
339        }
340    }
341
342    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
343        if let Some(first) = self.find_next_selectable(0) {
344            self.selection = Some(first);
345            self.list_state.scroll_to_reveal_item(first);
346            cx.notify();
347        }
348    }
349
350    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
351        let last = self.items.len().saturating_sub(1);
352        if let Some(last) = self.find_previous_selectable(last) {
353            self.selection = Some(last);
354            self.list_state.scroll_to_reveal_item(last);
355            cx.notify();
356        }
357    }
358
359    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
360        let Some(ix) = self.selection else { return };
361        let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
362            return;
363        };
364
365        if thread.folder_paths.is_empty() {
366            return;
367        }
368
369        self.unarchive_thread(thread.clone(), window, cx);
370    }
371
372    fn render_list_entry(
373        &mut self,
374        ix: usize,
375        _window: &mut Window,
376        cx: &mut Context<Self>,
377    ) -> AnyElement {
378        let Some(item) = self.items.get(ix) else {
379            return div().into_any_element();
380        };
381
382        match item {
383            ArchiveListItem::BucketSeparator(bucket) => div()
384                .w_full()
385                .px_2p5()
386                .pt_3()
387                .pb_1()
388                .child(
389                    Label::new(bucket.label())
390                        .size(LabelSize::Small)
391                        .color(Color::Muted),
392                )
393                .into_any_element(),
394            ArchiveListItem::Entry {
395                thread,
396                highlight_positions,
397            } => {
398                let id = SharedString::from(format!("archive-entry-{}", ix));
399
400                let is_focused = self.selection == Some(ix);
401                let is_hovered = self.hovered_index == Some(ix);
402
403                let focus_handle = self.focus_handle.clone();
404
405                let timestamp =
406                    format_history_entry_timestamp(thread.created_at.unwrap_or(thread.updated_at));
407
408                let icon_from_external_svg = self
409                    .agent_server_store
410                    .upgrade()
411                    .and_then(|store| store.read(cx).agent_icon(&thread.agent_id));
412
413                let icon = if thread.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
414                    IconName::ZedAgent
415                } else {
416                    IconName::Sparkle
417                };
418
419                ThreadItem::new(id, thread.title.clone())
420                    .icon(icon)
421                    .when_some(icon_from_external_svg, |this, svg| {
422                        this.custom_icon_from_external_svg(svg)
423                    })
424                    .timestamp(timestamp)
425                    .highlight_positions(highlight_positions.clone())
426                    .project_paths(thread.folder_paths.paths_owned())
427                    .focused(is_focused)
428                    .hovered(is_hovered)
429                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
430                        if *is_hovered {
431                            this.hovered_index = Some(ix);
432                        } else if this.hovered_index == Some(ix) {
433                            this.hovered_index = None;
434                        }
435                        cx.notify();
436                    }))
437                    .action_slot(
438                        h_flex()
439                            .gap_2()
440                            .when(is_hovered || is_focused, |this| {
441                                let focus_handle = self.focus_handle.clone();
442                                this.child(
443                                    Button::new("unarchive-thread", "Open")
444                                        .style(ButtonStyle::Filled)
445                                        .label_size(LabelSize::Small)
446                                        .when(is_focused, |this| {
447                                            this.key_binding(
448                                                KeyBinding::for_action_in(
449                                                    &menu::Confirm,
450                                                    &focus_handle,
451                                                    cx,
452                                                )
453                                                .map(|kb| kb.size(rems_from_px(12.))),
454                                            )
455                                        })
456                                        .on_click({
457                                            let thread = thread.clone();
458                                            cx.listener(move |this, _, window, cx| {
459                                                this.unarchive_thread(thread.clone(), window, cx);
460                                            })
461                                        }),
462                                )
463                            })
464                            .child(
465                                IconButton::new("delete-thread", IconName::Trash)
466                                    .style(ButtonStyle::Filled)
467                                    .icon_size(IconSize::Small)
468                                    .icon_color(Color::Muted)
469                                    .tooltip({
470                                        move |_window, cx| {
471                                            Tooltip::for_action_in(
472                                                "Delete Thread",
473                                                &RemoveSelectedThread,
474                                                &focus_handle,
475                                                cx,
476                                            )
477                                        }
478                                    })
479                                    .on_click({
480                                        let agent = thread.agent_id.clone();
481                                        let session_id = thread.session_id.clone();
482                                        cx.listener(move |this, _, _, cx| {
483                                            this.delete_thread(
484                                                session_id.clone(),
485                                                agent.clone(),
486                                                cx,
487                                            );
488                                            cx.stop_propagation();
489                                        })
490                                    }),
491                            ),
492                    )
493                    .into_any_element()
494            }
495        }
496    }
497
498    fn delete_thread(
499        &mut self,
500        session_id: acp::SessionId,
501        agent: AgentId,
502        cx: &mut Context<Self>,
503    ) {
504        ThreadMetadataStore::global(cx)
505            .update(cx, |store, cx| store.delete(session_id.clone(), cx));
506
507        let agent = Agent::from(agent);
508
509        let Some(agent_connection_store) = self.agent_connection_store.upgrade() else {
510            return;
511        };
512        let fs = <dyn Fs>::global(cx);
513
514        let task = agent_connection_store.update(cx, |store, cx| {
515            store
516                .request_connection(agent.clone(), agent.server(fs, ThreadStore::global(cx)), cx)
517                .read(cx)
518                .wait_for_connection()
519        });
520        cx.spawn(async move |_this, cx| {
521            let state = task.await?;
522            let task = cx.update(|cx| {
523                if let Some(list) = state.connection.session_list(cx) {
524                    list.delete_session(&session_id, cx)
525                } else {
526                    Task::ready(Ok(()))
527                }
528            });
529            task.await
530        })
531        .detach_and_log_err(cx);
532    }
533
534    fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
535        let Some(agent_server_store) = self.agent_server_store.upgrade() else {
536            return;
537        };
538        let Some(agent_registry_store) = self.agent_registry_store.upgrade() else {
539            return;
540        };
541
542        let workspace_handle = self.workspace.clone();
543        let multi_workspace = self.multi_workspace.clone();
544
545        self.workspace
546            .update(cx, |workspace, cx| {
547                workspace.toggle_modal(window, cx, |window, cx| {
548                    ThreadImportModal::new(
549                        agent_server_store,
550                        agent_registry_store,
551                        workspace_handle.clone(),
552                        multi_workspace.clone(),
553                        window,
554                        cx,
555                    )
556                });
557            })
558            .log_err();
559    }
560
561    fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
562        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
563        let sidebar_on_left = matches!(
564            AgentSettings::get_global(cx).sidebar_side(),
565            settings::SidebarSide::Left
566        );
567        let traffic_lights =
568            cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left;
569        let header_height = platform_title_bar_height(window);
570        let show_focus_keybinding =
571            self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window);
572
573        h_flex()
574            .h(header_height)
575            .mt_px()
576            .pb_px()
577            .map(|this| {
578                if traffic_lights {
579                    this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
580                } else {
581                    this.pl_1p5()
582                }
583            })
584            .pr_1p5()
585            .gap_1()
586            .justify_between()
587            .border_b_1()
588            .border_color(cx.theme().colors().border)
589            .when(traffic_lights, |this| {
590                this.child(Divider::vertical().color(ui::DividerColor::Border))
591            })
592            .child(
593                h_flex()
594                    .ml_1()
595                    .min_w_0()
596                    .w_full()
597                    .gap_1()
598                    .child(
599                        Icon::new(IconName::MagnifyingGlass)
600                            .size(IconSize::Small)
601                            .color(Color::Muted),
602                    )
603                    .child(self.filter_editor.clone()),
604            )
605            .when(show_focus_keybinding, |this| {
606                this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
607            })
608            .map(|this| {
609                if has_query {
610                    this.child(
611                        IconButton::new("clear-filter", IconName::Close)
612                            .icon_size(IconSize::Small)
613                            .tooltip(Tooltip::text("Clear Search"))
614                            .on_click(cx.listener(|this, _, window, cx| {
615                                this.reset_filter_editor_text(window, cx);
616                                this.update_items(cx);
617                            })),
618                    )
619                } else {
620                    this.child(
621                        IconButton::new("import-thread", IconName::Plus)
622                            .icon_size(IconSize::Small)
623                            .tooltip(Tooltip::text("Import ACP Threads"))
624                            .on_click(cx.listener(|this, _, window, cx| {
625                                this.show_thread_import_modal(window, cx);
626                            })),
627                    )
628                }
629            })
630    }
631}
632
633pub fn format_history_entry_timestamp(entry_time: DateTime<Utc>) -> String {
634    let now = Utc::now();
635    let duration = now.signed_duration_since(entry_time);
636
637    let minutes = duration.num_minutes();
638    let hours = duration.num_hours();
639    let days = duration.num_days();
640    let weeks = days / 7;
641    let months = days / 30;
642
643    if minutes < 60 {
644        format!("{}m", minutes.max(1))
645    } else if hours < 24 {
646        format!("{}h", hours.max(1))
647    } else if days < 7 {
648        format!("{}d", days.max(1))
649    } else if weeks < 4 {
650        format!("{}w", weeks.max(1))
651    } else {
652        format!("{}mo", months.max(1))
653    }
654}
655
656impl Focusable for ThreadsArchiveView {
657    fn focus_handle(&self, _cx: &App) -> FocusHandle {
658        self.focus_handle.clone()
659    }
660}
661
662impl Render for ThreadsArchiveView {
663    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
664        let is_empty = self.items.is_empty();
665        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
666
667        let content = if is_empty {
668            let message = if has_query {
669                "No threads match your search."
670            } else {
671                "No archived or hidden threads yet."
672            };
673
674            v_flex()
675                .flex_1()
676                .justify_center()
677                .items_center()
678                .child(
679                    Label::new(message)
680                        .size(LabelSize::Small)
681                        .color(Color::Muted),
682                )
683                .into_any_element()
684        } else {
685            v_flex()
686                .flex_1()
687                .overflow_hidden()
688                .child(
689                    list(
690                        self.list_state.clone(),
691                        cx.processor(Self::render_list_entry),
692                    )
693                    .flex_1()
694                    .size_full(),
695                )
696                .vertical_scrollbar_for(&self.list_state, window, cx)
697                .into_any_element()
698        };
699
700        v_flex()
701            .key_context("ThreadsArchiveView")
702            .track_focus(&self.focus_handle)
703            .on_action(cx.listener(Self::select_next))
704            .on_action(cx.listener(Self::select_previous))
705            .on_action(cx.listener(Self::editor_move_down))
706            .on_action(cx.listener(Self::editor_move_up))
707            .on_action(cx.listener(Self::select_first))
708            .on_action(cx.listener(Self::select_last))
709            .on_action(cx.listener(Self::confirm))
710            .size_full()
711            .child(self.render_header(window, cx))
712            .child(content)
713    }
714}