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}