threads_archive_view.rs

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