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