1use crate::agent_connection_store::AgentConnectionStore;
2use crate::thread_import::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 filter_editor: Entity<Editor>,
112 _subscriptions: Vec<gpui::Subscription>,
113 _refresh_history_task: Task<()>,
114 agent_connection_store: WeakEntity<AgentConnectionStore>,
115 agent_server_store: WeakEntity<AgentServerStore>,
116 agent_registry_store: WeakEntity<AgentRegistryStore>,
117 workspace: WeakEntity<Workspace>,
118 multi_workspace: WeakEntity<MultiWorkspace>,
119}
120
121impl ThreadsArchiveView {
122 pub fn new(
123 agent_connection_store: WeakEntity<AgentConnectionStore>,
124 agent_server_store: WeakEntity<AgentServerStore>,
125 agent_registry_store: WeakEntity<AgentRegistryStore>,
126 workspace: WeakEntity<Workspace>,
127 multi_workspace: WeakEntity<MultiWorkspace>,
128 window: &mut Window,
129 cx: &mut Context<Self>,
130 ) -> Self {
131 let focus_handle = cx.focus_handle();
132
133 let filter_editor = cx.new(|cx| {
134 let mut editor = Editor::single_line(window, cx);
135 editor.set_placeholder_text("Search archive…", window, cx);
136 editor
137 });
138
139 let filter_editor_subscription =
140 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
141 if let editor::EditorEvent::BufferEdited = event {
142 this.update_items(cx);
143 }
144 });
145
146 let filter_focus_handle = filter_editor.read(cx).focus_handle(cx);
147 cx.on_focus_in(
148 &filter_focus_handle,
149 window,
150 |this: &mut Self, _window, cx| {
151 if this.selection.is_some() {
152 this.selection = None;
153 cx.notify();
154 }
155 },
156 )
157 .detach();
158
159 let thread_metadata_store_subscription = cx.observe(
160 &ThreadMetadataStore::global(cx),
161 |this: &mut Self, _, cx| {
162 this.update_items(cx);
163 },
164 );
165
166 cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, _window, cx| {
167 this.selection = None;
168 cx.notify();
169 })
170 .detach();
171
172 let mut this = Self {
173 _history_subscription: Subscription::new(|| {}),
174 focus_handle,
175 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
176 items: Vec::new(),
177 selection: None,
178 hovered_index: None,
179 filter_editor,
180 _subscriptions: vec![
181 filter_editor_subscription,
182 thread_metadata_store_subscription,
183 ],
184 _refresh_history_task: Task::ready(()),
185 agent_registry_store,
186 agent_connection_store,
187 agent_server_store,
188 workspace,
189 multi_workspace,
190 };
191
192 this.update_items(cx);
193 this
194 }
195
196 pub fn has_selection(&self) -> bool {
197 self.selection.is_some()
198 }
199
200 pub fn clear_selection(&mut self) {
201 self.selection = None;
202 }
203
204 pub fn focus_filter_editor(&self, window: &mut Window, cx: &mut App) {
205 let handle = self.filter_editor.read(cx).focus_handle(cx);
206 handle.focus(window, cx);
207 }
208
209 fn update_items(&mut self, cx: &mut Context<Self>) {
210 let sessions = ThreadMetadataStore::global(cx)
211 .read(cx)
212 .archived_entries()
213 .sorted_by_cached_key(|t| t.created_at.unwrap_or(t.updated_at))
214 .rev()
215 .cloned()
216 .collect::<Vec<_>>();
217
218 let query = self.filter_editor.read(cx).text(cx).to_lowercase();
219 let today = Local::now().naive_local().date();
220
221 let mut items = Vec::with_capacity(sessions.len() + 5);
222 let mut current_bucket: Option<TimeBucket> = None;
223
224 for session in sessions {
225 let highlight_positions = if !query.is_empty() {
226 match fuzzy_match_positions(&query, &session.title) {
227 Some(positions) => positions,
228 None => continue,
229 }
230 } else {
231 Vec::new()
232 };
233
234 let entry_bucket = {
235 let entry_date = session
236 .created_at
237 .unwrap_or(session.updated_at)
238 .with_timezone(&Local)
239 .naive_local()
240 .date();
241 TimeBucket::from_dates(today, entry_date)
242 };
243
244 if Some(entry_bucket) != current_bucket {
245 current_bucket = Some(entry_bucket);
246 items.push(ArchiveListItem::BucketSeparator(entry_bucket));
247 }
248
249 items.push(ArchiveListItem::Entry {
250 thread: session,
251 highlight_positions,
252 });
253 }
254
255 self.list_state.reset(items.len());
256 self.items = items;
257 self.selection = None;
258 self.hovered_index = None;
259 cx.notify();
260 }
261
262 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
263 self.filter_editor.update(cx, |editor, cx| {
264 editor.set_text("", window, cx);
265 });
266 }
267
268 fn unarchive_thread(
269 &mut self,
270 thread: ThreadMetadata,
271 window: &mut Window,
272 cx: &mut Context<Self>,
273 ) {
274 self.selection = None;
275 self.reset_filter_editor_text(window, cx);
276 cx.emit(ThreadsArchiveViewEvent::Unarchive { thread });
277 }
278
279 fn is_selectable_item(&self, ix: usize) -> bool {
280 matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
281 }
282
283 fn find_next_selectable(&self, start: usize) -> Option<usize> {
284 (start..self.items.len()).find(|&i| self.is_selectable_item(i))
285 }
286
287 fn find_previous_selectable(&self, start: usize) -> Option<usize> {
288 (0..=start).rev().find(|&i| self.is_selectable_item(i))
289 }
290
291 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
292 self.select_next(&SelectNext, window, cx);
293 if self.selection.is_some() {
294 self.focus_handle.focus(window, cx);
295 }
296 }
297
298 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
299 self.select_previous(&SelectPrevious, window, cx);
300 if self.selection.is_some() {
301 self.focus_handle.focus(window, cx);
302 }
303 }
304
305 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
306 let next = match self.selection {
307 Some(ix) => self.find_next_selectable(ix + 1),
308 None => self.find_next_selectable(0),
309 };
310 if let Some(next) = next {
311 self.selection = Some(next);
312 self.list_state.scroll_to_reveal_item(next);
313 cx.notify();
314 }
315 }
316
317 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
318 match self.selection {
319 Some(ix) => {
320 if let Some(prev) = (ix > 0)
321 .then(|| self.find_previous_selectable(ix - 1))
322 .flatten()
323 {
324 self.selection = Some(prev);
325 self.list_state.scroll_to_reveal_item(prev);
326 } else {
327 self.selection = None;
328 self.focus_filter_editor(window, cx);
329 }
330 cx.notify();
331 }
332 None => {
333 let last = self.items.len().saturating_sub(1);
334 if let Some(prev) = self.find_previous_selectable(last) {
335 self.selection = Some(prev);
336 self.list_state.scroll_to_reveal_item(prev);
337 cx.notify();
338 }
339 }
340 }
341 }
342
343 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
344 if let Some(first) = self.find_next_selectable(0) {
345 self.selection = Some(first);
346 self.list_state.scroll_to_reveal_item(first);
347 cx.notify();
348 }
349 }
350
351 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
352 let last = self.items.len().saturating_sub(1);
353 if let Some(last) = self.find_previous_selectable(last) {
354 self.selection = Some(last);
355 self.list_state.scroll_to_reveal_item(last);
356 cx.notify();
357 }
358 }
359
360 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
361 let Some(ix) = self.selection else { return };
362 let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
363 return;
364 };
365
366 if thread.folder_paths.is_empty() {
367 return;
368 }
369
370 self.unarchive_thread(thread.clone(), window, cx);
371 }
372
373 fn render_list_entry(
374 &mut self,
375 ix: usize,
376 _window: &mut Window,
377 cx: &mut Context<Self>,
378 ) -> AnyElement {
379 let Some(item) = self.items.get(ix) else {
380 return div().into_any_element();
381 };
382
383 match item {
384 ArchiveListItem::BucketSeparator(bucket) => div()
385 .w_full()
386 .px_2p5()
387 .pt_3()
388 .pb_1()
389 .child(
390 Label::new(bucket.label())
391 .size(LabelSize::Small)
392 .color(Color::Muted),
393 )
394 .into_any_element(),
395 ArchiveListItem::Entry {
396 thread,
397 highlight_positions,
398 } => {
399 let id = SharedString::from(format!("archive-entry-{}", ix));
400
401 let is_focused = self.selection == Some(ix);
402 let is_hovered = self.hovered_index == Some(ix);
403
404 let focus_handle = self.focus_handle.clone();
405
406 let timestamp =
407 format_history_entry_timestamp(thread.created_at.unwrap_or(thread.updated_at));
408
409 let icon_from_external_svg = self
410 .agent_server_store
411 .upgrade()
412 .and_then(|store| store.read(cx).agent_icon(&thread.agent_id));
413
414 let icon = if thread.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
415 IconName::ZedAgent
416 } else {
417 IconName::Sparkle
418 };
419
420 ThreadItem::new(id, thread.title.clone())
421 .icon(icon)
422 .when_some(icon_from_external_svg, |this, svg| {
423 this.custom_icon_from_external_svg(svg)
424 })
425 .timestamp(timestamp)
426 .highlight_positions(highlight_positions.clone())
427 .project_paths(thread.folder_paths.paths_owned())
428 .focused(is_focused)
429 .hovered(is_hovered)
430 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
431 if *is_hovered {
432 this.hovered_index = Some(ix);
433 } else if this.hovered_index == Some(ix) {
434 this.hovered_index = None;
435 }
436 cx.notify();
437 }))
438 .action_slot(
439 h_flex()
440 .gap_2()
441 .when(is_hovered || is_focused, |this| {
442 let focus_handle = self.focus_handle.clone();
443 this.child(
444 Button::new("unarchive-thread", "Open")
445 .style(ButtonStyle::Filled)
446 .label_size(LabelSize::Small)
447 .when(is_focused, |this| {
448 this.key_binding(
449 KeyBinding::for_action_in(
450 &menu::Confirm,
451 &focus_handle,
452 cx,
453 )
454 .map(|kb| kb.size(rems_from_px(12.))),
455 )
456 })
457 .on_click({
458 let thread = thread.clone();
459 cx.listener(move |this, _, window, cx| {
460 this.unarchive_thread(thread.clone(), window, cx);
461 })
462 }),
463 )
464 })
465 .child(
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(
485 session_id.clone(),
486 agent.clone(),
487 cx,
488 );
489 cx.stop_propagation();
490 })
491 }),
492 ),
493 )
494 .into_any_element()
495 }
496 }
497 }
498
499 fn delete_thread(
500 &mut self,
501 session_id: acp::SessionId,
502 agent: AgentId,
503 cx: &mut Context<Self>,
504 ) {
505 ThreadMetadataStore::global(cx)
506 .update(cx, |store, cx| store.delete(session_id.clone(), cx));
507
508 let agent = Agent::from(agent);
509
510 let Some(agent_connection_store) = self.agent_connection_store.upgrade() else {
511 return;
512 };
513 let fs = <dyn Fs>::global(cx);
514
515 let task = agent_connection_store.update(cx, |store, cx| {
516 store
517 .request_connection(agent.clone(), agent.server(fs, ThreadStore::global(cx)), cx)
518 .read(cx)
519 .wait_for_connection()
520 });
521 cx.spawn(async move |_this, cx| {
522 let state = task.await?;
523 let task = cx.update(|cx| {
524 if let Some(list) = state.connection.session_list(cx) {
525 list.delete_session(&session_id, cx)
526 } else {
527 Task::ready(Ok(()))
528 }
529 });
530 task.await
531 })
532 .detach_and_log_err(cx);
533 }
534
535 fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
536 let Some(agent_server_store) = self.agent_server_store.upgrade() else {
537 return;
538 };
539 let Some(agent_registry_store) = self.agent_registry_store.upgrade() else {
540 return;
541 };
542
543 let workspace_handle = self.workspace.clone();
544 let multi_workspace = self.multi_workspace.clone();
545
546 self.workspace
547 .update(cx, |workspace, cx| {
548 workspace.toggle_modal(window, cx, |window, cx| {
549 ThreadImportModal::new(
550 agent_server_store,
551 agent_registry_store,
552 workspace_handle.clone(),
553 multi_workspace.clone(),
554 window,
555 cx,
556 )
557 });
558 })
559 .log_err();
560 }
561
562 fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
563 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
564 let sidebar_on_left = matches!(
565 AgentSettings::get_global(cx).sidebar_side(),
566 settings::SidebarSide::Left
567 );
568 let traffic_lights =
569 cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left;
570 let header_height = platform_title_bar_height(window);
571 let show_focus_keybinding =
572 self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window);
573
574 h_flex()
575 .h(header_height)
576 .mt_px()
577 .pb_px()
578 .map(|this| {
579 if traffic_lights {
580 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
581 } else {
582 this.pl_1p5()
583 }
584 })
585 .pr_1p5()
586 .gap_1()
587 .justify_between()
588 .border_b_1()
589 .border_color(cx.theme().colors().border)
590 .when(traffic_lights, |this| {
591 this.child(Divider::vertical().color(ui::DividerColor::Border))
592 })
593 .child(
594 h_flex()
595 .ml_1()
596 .min_w_0()
597 .w_full()
598 .gap_1()
599 .child(
600 Icon::new(IconName::MagnifyingGlass)
601 .size(IconSize::Small)
602 .color(Color::Muted),
603 )
604 .child(self.filter_editor.clone()),
605 )
606 .when(show_focus_keybinding, |this| {
607 this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
608 })
609 .map(|this| {
610 if has_query {
611 this.child(
612 IconButton::new("clear-filter", IconName::Close)
613 .icon_size(IconSize::Small)
614 .tooltip(Tooltip::text("Clear Search"))
615 .on_click(cx.listener(|this, _, window, cx| {
616 this.reset_filter_editor_text(window, cx);
617 this.update_items(cx);
618 })),
619 )
620 } else {
621 this.child(
622 IconButton::new("import-thread", IconName::Plus)
623 .icon_size(IconSize::Small)
624 .tooltip(Tooltip::text("Import ACP Threads"))
625 .on_click(cx.listener(|this, _, window, cx| {
626 this.show_thread_import_modal(window, cx);
627 })),
628 )
629 }
630 })
631 }
632}
633
634pub fn format_history_entry_timestamp(entry_time: DateTime<Utc>) -> String {
635 let now = Utc::now();
636 let duration = now.signed_duration_since(entry_time);
637
638 let minutes = duration.num_minutes();
639 let hours = duration.num_hours();
640 let days = duration.num_days();
641 let weeks = days / 7;
642 let months = days / 30;
643
644 if minutes < 60 {
645 format!("{}m", minutes.max(1))
646 } else if hours < 24 {
647 format!("{}h", hours.max(1))
648 } else if days < 7 {
649 format!("{}d", days.max(1))
650 } else if weeks < 4 {
651 format!("{}w", weeks.max(1))
652 } else {
653 format!("{}mo", months.max(1))
654 }
655}
656
657impl Focusable for ThreadsArchiveView {
658 fn focus_handle(&self, _cx: &App) -> FocusHandle {
659 self.focus_handle.clone()
660 }
661}
662
663impl Render for ThreadsArchiveView {
664 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
665 let is_empty = self.items.is_empty();
666 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
667
668 let content = if is_empty {
669 let message = if has_query {
670 "No threads match your search."
671 } else {
672 "No archived or hidden threads yet."
673 };
674
675 v_flex()
676 .flex_1()
677 .justify_center()
678 .items_center()
679 .child(
680 Label::new(message)
681 .size(LabelSize::Small)
682 .color(Color::Muted),
683 )
684 .into_any_element()
685 } else {
686 v_flex()
687 .flex_1()
688 .overflow_hidden()
689 .child(
690 list(
691 self.list_state.clone(),
692 cx.processor(Self::render_list_entry),
693 )
694 .flex_1()
695 .size_full(),
696 )
697 .vertical_scrollbar_for(&self.list_state, window, cx)
698 .into_any_element()
699 };
700
701 v_flex()
702 .key_context("ThreadsArchiveView")
703 .track_focus(&self.focus_handle)
704 .on_action(cx.listener(Self::select_next))
705 .on_action(cx.listener(Self::select_previous))
706 .on_action(cx.listener(Self::editor_move_down))
707 .on_action(cx.listener(Self::editor_move_up))
708 .on_action(cx.listener(Self::select_first))
709 .on_action(cx.listener(Self::select_last))
710 .on_action(cx.listener(Self::confirm))
711 .size_full()
712 .child(self.render_header(window, cx))
713 .child(content)
714 }
715}