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