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}