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 .collect::<Vec<_>>();
218
219 let query = self.filter_editor.read(cx).text(cx).to_lowercase();
220 let today = Local::now().naive_local().date();
221
222 let mut items = Vec::with_capacity(sessions.len() + 5);
223 let mut current_bucket: Option<TimeBucket> = None;
224
225 for session in sessions {
226 let highlight_positions = if !query.is_empty() {
227 match fuzzy_match_positions(&query, &session.title) {
228 Some(positions) => positions,
229 None => continue,
230 }
231 } else {
232 Vec::new()
233 };
234
235 let entry_bucket = {
236 let entry_date = session
237 .created_at
238 .unwrap_or(session.updated_at)
239 .with_timezone(&Local)
240 .naive_local()
241 .date();
242 TimeBucket::from_dates(today, entry_date)
243 };
244
245 if Some(entry_bucket) != current_bucket {
246 current_bucket = Some(entry_bucket);
247 items.push(ArchiveListItem::BucketSeparator(entry_bucket));
248 }
249
250 items.push(ArchiveListItem::Entry {
251 thread: session,
252 highlight_positions,
253 });
254 }
255
256 let preserve = self.preserve_selection_on_next_update;
257 self.preserve_selection_on_next_update = false;
258
259 let saved_scroll = if preserve {
260 Some(self.list_state.logical_scroll_top())
261 } else {
262 None
263 };
264
265 self.list_state.reset(items.len());
266 self.items = items;
267 self.hovered_index = None;
268
269 if let Some(scroll_top) = saved_scroll {
270 self.list_state.scroll_to(scroll_top);
271
272 if let Some(ix) = self.selection {
273 let next = self.find_next_selectable(ix).or_else(|| {
274 ix.checked_sub(1)
275 .and_then(|i| self.find_previous_selectable(i))
276 });
277 self.selection = next;
278 if let Some(next) = next {
279 self.list_state.scroll_to_reveal_item(next);
280 }
281 }
282 } else {
283 self.selection = None;
284 }
285
286 cx.notify();
287 }
288
289 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
290 self.filter_editor.update(cx, |editor, cx| {
291 editor.set_text("", window, cx);
292 });
293 }
294
295 fn unarchive_thread(
296 &mut self,
297 thread: ThreadMetadata,
298 window: &mut Window,
299 cx: &mut Context<Self>,
300 ) {
301 self.selection = None;
302 self.reset_filter_editor_text(window, cx);
303 cx.emit(ThreadsArchiveViewEvent::Unarchive { thread });
304 }
305
306 fn is_selectable_item(&self, ix: usize) -> bool {
307 matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
308 }
309
310 fn find_next_selectable(&self, start: usize) -> Option<usize> {
311 (start..self.items.len()).find(|&i| self.is_selectable_item(i))
312 }
313
314 fn find_previous_selectable(&self, start: usize) -> Option<usize> {
315 (0..=start).rev().find(|&i| self.is_selectable_item(i))
316 }
317
318 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
319 self.select_next(&SelectNext, window, cx);
320 if self.selection.is_some() {
321 self.focus_handle.focus(window, cx);
322 }
323 }
324
325 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
326 self.select_previous(&SelectPrevious, window, cx);
327 if self.selection.is_some() {
328 self.focus_handle.focus(window, cx);
329 }
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(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
345 match self.selection {
346 Some(ix) => {
347 if let Some(prev) = (ix > 0)
348 .then(|| self.find_previous_selectable(ix - 1))
349 .flatten()
350 {
351 self.selection = Some(prev);
352 self.list_state.scroll_to_reveal_item(prev);
353 } else {
354 self.selection = None;
355 self.focus_filter_editor(window, cx);
356 }
357 cx.notify();
358 }
359 None => {
360 let last = self.items.len().saturating_sub(1);
361 if let Some(prev) = self.find_previous_selectable(last) {
362 self.selection = Some(prev);
363 self.list_state.scroll_to_reveal_item(prev);
364 cx.notify();
365 }
366 }
367 }
368 }
369
370 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
371 if let Some(first) = self.find_next_selectable(0) {
372 self.selection = Some(first);
373 self.list_state.scroll_to_reveal_item(first);
374 cx.notify();
375 }
376 }
377
378 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
379 let last = self.items.len().saturating_sub(1);
380 if let Some(last) = self.find_previous_selectable(last) {
381 self.selection = Some(last);
382 self.list_state.scroll_to_reveal_item(last);
383 cx.notify();
384 }
385 }
386
387 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
388 let Some(ix) = self.selection else { return };
389 let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
390 return;
391 };
392
393 if thread.folder_paths.is_empty() {
394 return;
395 }
396
397 self.unarchive_thread(thread.clone(), window, cx);
398 }
399
400 fn render_list_entry(
401 &mut self,
402 ix: usize,
403 _window: &mut Window,
404 cx: &mut Context<Self>,
405 ) -> AnyElement {
406 let Some(item) = self.items.get(ix) else {
407 return div().into_any_element();
408 };
409
410 match item {
411 ArchiveListItem::BucketSeparator(bucket) => div()
412 .w_full()
413 .px_2p5()
414 .pt_3()
415 .pb_1()
416 .child(
417 Label::new(bucket.label())
418 .size(LabelSize::Small)
419 .color(Color::Muted),
420 )
421 .into_any_element(),
422 ArchiveListItem::Entry {
423 thread,
424 highlight_positions,
425 } => {
426 let id = SharedString::from(format!("archive-entry-{}", ix));
427
428 let is_focused = self.selection == Some(ix);
429 let is_hovered = self.hovered_index == Some(ix);
430
431 let focus_handle = self.focus_handle.clone();
432
433 let timestamp =
434 format_history_entry_timestamp(thread.created_at.unwrap_or(thread.updated_at));
435
436 let icon_from_external_svg = self
437 .agent_server_store
438 .upgrade()
439 .and_then(|store| store.read(cx).agent_icon(&thread.agent_id));
440
441 let icon = if thread.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
442 IconName::ZedAgent
443 } else {
444 IconName::Sparkle
445 };
446
447 ThreadItem::new(id, thread.title.clone())
448 .icon(icon)
449 .when_some(icon_from_external_svg, |this, svg| {
450 this.custom_icon_from_external_svg(svg)
451 })
452 .timestamp(timestamp)
453 .highlight_positions(highlight_positions.clone())
454 .project_paths(thread.folder_paths.paths_owned())
455 .focused(is_focused)
456 .hovered(is_hovered)
457 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
458 if *is_hovered {
459 this.hovered_index = Some(ix);
460 } else if this.hovered_index == Some(ix) {
461 this.hovered_index = None;
462 }
463 cx.notify();
464 }))
465 .action_slot(
466 IconButton::new("delete-thread", IconName::Trash)
467 .style(ButtonStyle::Filled)
468 .icon_size(IconSize::Small)
469 .icon_color(Color::Muted)
470 .tooltip({
471 move |_window, cx| {
472 Tooltip::for_action_in(
473 "Delete Thread",
474 &RemoveSelectedThread,
475 &focus_handle,
476 cx,
477 )
478 }
479 })
480 .on_click({
481 let agent = thread.agent_id.clone();
482 let session_id = thread.session_id.clone();
483 cx.listener(move |this, _, _, cx| {
484 this.delete_thread(session_id.clone(), agent.clone(), cx);
485 cx.stop_propagation();
486 })
487 }),
488 )
489 .tooltip(move |_, cx| Tooltip::for_action("Restore Thread", &menu::Confirm, cx))
490 .on_click({
491 let thread = thread.clone();
492 cx.listener(move |this, _, window, cx| {
493 this.unarchive_thread(thread.clone(), window, cx);
494 })
495 })
496 .into_any_element()
497 }
498 }
499 }
500
501 fn remove_selected_thread(
502 &mut self,
503 _: &RemoveSelectedThread,
504 _window: &mut Window,
505 cx: &mut Context<Self>,
506 ) {
507 let Some(ix) = self.selection else { return };
508 let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
509 return;
510 };
511
512 self.preserve_selection_on_next_update = true;
513 self.delete_thread(thread.session_id.clone(), thread.agent_id.clone(), cx);
514 }
515
516 fn delete_thread(
517 &mut self,
518 session_id: acp::SessionId,
519 agent: AgentId,
520 cx: &mut Context<Self>,
521 ) {
522 ThreadMetadataStore::global(cx)
523 .update(cx, |store, cx| store.delete(session_id.clone(), cx));
524
525 let agent = Agent::from(agent);
526
527 let Some(agent_connection_store) = self.agent_connection_store.upgrade() else {
528 return;
529 };
530 let fs = <dyn Fs>::global(cx);
531
532 let task = agent_connection_store.update(cx, |store, cx| {
533 store
534 .request_connection(agent.clone(), agent.server(fs, ThreadStore::global(cx)), cx)
535 .read(cx)
536 .wait_for_connection()
537 });
538 cx.spawn(async move |_this, cx| {
539 let state = task.await?;
540 let task = cx.update(|cx| {
541 if let Some(list) = state.connection.session_list(cx) {
542 list.delete_session(&session_id, cx)
543 } else {
544 Task::ready(Ok(()))
545 }
546 });
547 task.await
548 })
549 .detach_and_log_err(cx);
550 }
551
552 fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
553 let has_external_agents = self
554 .agent_server_store
555 .upgrade()
556 .map(|store| store.read(cx).has_external_agents())
557 .unwrap_or(false);
558
559 has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
560 }
561
562 fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
563 let Some(agent_server_store) = self.agent_server_store.upgrade() else {
564 return;
565 };
566 let Some(agent_registry_store) = self.agent_registry_store.upgrade() else {
567 return;
568 };
569
570 let workspace_handle = self.workspace.clone();
571 let multi_workspace = self.multi_workspace.clone();
572
573 self.workspace
574 .update(cx, |workspace, cx| {
575 workspace.toggle_modal(window, cx, |window, cx| {
576 ThreadImportModal::new(
577 agent_server_store,
578 agent_registry_store,
579 workspace_handle.clone(),
580 multi_workspace.clone(),
581 window,
582 cx,
583 )
584 });
585 })
586 .log_err();
587 }
588
589 fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
590 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
591 let sidebar_on_left = matches!(
592 AgentSettings::get_global(cx).sidebar_side(),
593 settings::SidebarSide::Left
594 );
595 let traffic_lights =
596 cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left;
597 let header_height = platform_title_bar_height(window);
598 let show_focus_keybinding =
599 self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window);
600
601 h_flex()
602 .h(header_height)
603 .mt_px()
604 .pb_px()
605 .map(|this| {
606 if traffic_lights {
607 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
608 } else {
609 this.pl_1p5()
610 }
611 })
612 .pr_1p5()
613 .gap_1()
614 .justify_between()
615 .border_b_1()
616 .border_color(cx.theme().colors().border)
617 .when(traffic_lights, |this| {
618 this.child(Divider::vertical().color(ui::DividerColor::Border))
619 })
620 .child(
621 h_flex()
622 .ml_1()
623 .min_w_0()
624 .w_full()
625 .gap_1()
626 .child(
627 Icon::new(IconName::MagnifyingGlass)
628 .size(IconSize::Small)
629 .color(Color::Muted),
630 )
631 .child(self.filter_editor.clone()),
632 )
633 .when(show_focus_keybinding, |this| {
634 this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
635 })
636 .when(has_query, |this| {
637 this.child(
638 IconButton::new("clear-filter", IconName::Close)
639 .icon_size(IconSize::Small)
640 .tooltip(Tooltip::text("Clear Search"))
641 .on_click(cx.listener(|this, _, window, cx| {
642 this.reset_filter_editor_text(window, cx);
643 this.update_items(cx);
644 })),
645 )
646 })
647 }
648}
649
650pub fn format_history_entry_timestamp(entry_time: DateTime<Utc>) -> String {
651 let now = Utc::now();
652 let duration = now.signed_duration_since(entry_time);
653
654 let minutes = duration.num_minutes();
655 let hours = duration.num_hours();
656 let days = duration.num_days();
657 let weeks = days / 7;
658 let months = days / 30;
659
660 if minutes < 60 {
661 format!("{}m", minutes.max(1))
662 } else if hours < 24 {
663 format!("{}h", hours.max(1))
664 } else if days < 7 {
665 format!("{}d", days.max(1))
666 } else if weeks < 4 {
667 format!("{}w", weeks.max(1))
668 } else {
669 format!("{}mo", months.max(1))
670 }
671}
672
673impl Focusable for ThreadsArchiveView {
674 fn focus_handle(&self, _cx: &App) -> FocusHandle {
675 self.focus_handle.clone()
676 }
677}
678
679impl Render for ThreadsArchiveView {
680 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
681 let is_empty = self.items.is_empty();
682 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
683
684 let content = if is_empty {
685 let message = if has_query {
686 "No threads match your search."
687 } else {
688 "No archived or hidden threads yet."
689 };
690
691 v_flex()
692 .flex_1()
693 .justify_center()
694 .items_center()
695 .child(
696 Label::new(message)
697 .size(LabelSize::Small)
698 .color(Color::Muted),
699 )
700 .into_any_element()
701 } else {
702 v_flex()
703 .flex_1()
704 .overflow_hidden()
705 .child(
706 list(
707 self.list_state.clone(),
708 cx.processor(Self::render_list_entry),
709 )
710 .flex_1()
711 .size_full(),
712 )
713 .vertical_scrollbar_for(&self.list_state, window, cx)
714 .into_any_element()
715 };
716
717 v_flex()
718 .key_context("ThreadsArchiveView")
719 .track_focus(&self.focus_handle)
720 .on_action(cx.listener(Self::select_next))
721 .on_action(cx.listener(Self::select_previous))
722 .on_action(cx.listener(Self::editor_move_down))
723 .on_action(cx.listener(Self::editor_move_up))
724 .on_action(cx.listener(Self::select_first))
725 .on_action(cx.listener(Self::select_last))
726 .on_action(cx.listener(Self::confirm))
727 .on_action(cx.listener(Self::remove_selected_thread))
728 .size_full()
729 .child(self.render_header(window, cx))
730 .child(content)
731 .when(!self.should_render_acp_import_onboarding(cx), |this| {
732 this.child(
733 div()
734 .w_full()
735 .p_1p5()
736 .border_t_1()
737 .border_color(cx.theme().colors().border)
738 .child(
739 Button::new("import-acp", "Import ACP Threads")
740 .full_width()
741 .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
742 .label_size(LabelSize::Small)
743 .start_icon(
744 Icon::new(IconName::ArrowDown)
745 .size(IconSize::XSmall)
746 .color(Color::Muted),
747 )
748 .on_click(cx.listener(|this, _, window, cx| {
749 this.show_thread_import_modal(window, cx);
750 })),
751 ),
752 )
753 })
754 }
755}