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