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