1use std::sync::Arc;
2
3use assistant_context_editor::SavedContextMetadata;
4use editor::{Editor, EditorEvent};
5use fuzzy::{StringMatch, StringMatchCandidate};
6use gpui::{
7 App, Entity, FocusHandle, Focusable, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity,
8 Window, uniform_list,
9};
10use time::{OffsetDateTime, UtcOffset};
11use ui::{HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tooltip, prelude::*};
12use util::ResultExt;
13
14use crate::history_store::{HistoryEntry, HistoryStore};
15use crate::thread_store::SerializedThreadMetadata;
16use crate::{AssistantPanel, RemoveSelectedThread};
17
18pub struct ThreadHistory {
19 assistant_panel: WeakEntity<AssistantPanel>,
20 history_store: Entity<HistoryStore>,
21 scroll_handle: UniformListScrollHandle,
22 selected_index: usize,
23 search_query: SharedString,
24 search_editor: Entity<Editor>,
25 all_entries: Arc<Vec<HistoryEntry>>,
26 matches: Vec<StringMatch>,
27 _subscriptions: Vec<gpui::Subscription>,
28 _search_task: Option<Task<()>>,
29}
30
31impl ThreadHistory {
32 pub(crate) fn new(
33 assistant_panel: WeakEntity<AssistantPanel>,
34 history_store: Entity<HistoryStore>,
35 window: &mut Window,
36 cx: &mut Context<Self>,
37 ) -> Self {
38 let search_editor = cx.new(|cx| {
39 let mut editor = Editor::single_line(window, cx);
40 editor.set_placeholder_text("Search threads...", cx);
41 editor
42 });
43
44 let search_editor_subscription =
45 cx.subscribe(&search_editor, |this, search_editor, event, cx| {
46 if let EditorEvent::BufferEdited = event {
47 let query = search_editor.read(cx).text(cx);
48 this.search_query = query.into();
49 this.update_search(cx);
50 }
51 });
52
53 let entries: Arc<Vec<_>> = history_store
54 .update(cx, |store, cx| store.entries(cx))
55 .into();
56
57 let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
58 this.update_all_entries(cx);
59 });
60
61 Self {
62 assistant_panel,
63 history_store,
64 scroll_handle: UniformListScrollHandle::default(),
65 selected_index: 0,
66 search_query: SharedString::new_static(""),
67 all_entries: entries,
68 matches: Vec::new(),
69 search_editor,
70 _subscriptions: vec![search_editor_subscription, history_store_subscription],
71 _search_task: None,
72 }
73 }
74
75 fn update_all_entries(&mut self, cx: &mut Context<Self>) {
76 self.all_entries = self
77 .history_store
78 .update(cx, |store, cx| store.entries(cx))
79 .into();
80 self.matches.clear();
81 self.update_search(cx);
82 }
83
84 fn update_search(&mut self, cx: &mut Context<Self>) {
85 self._search_task.take();
86
87 if self.has_search_query() {
88 self.perform_search(cx);
89 } else {
90 self.matches.clear();
91 self.set_selected_index(0, cx);
92 cx.notify();
93 }
94 }
95
96 fn perform_search(&mut self, cx: &mut Context<Self>) {
97 let query = self.search_query.clone();
98 let all_entries = self.all_entries.clone();
99
100 let task = cx.spawn(async move |this, cx| {
101 let executor = cx.background_executor().clone();
102
103 let matches = cx
104 .background_spawn(async move {
105 let mut candidates = Vec::with_capacity(all_entries.len());
106
107 for (idx, entry) in all_entries.iter().enumerate() {
108 match entry {
109 HistoryEntry::Thread(thread) => {
110 candidates.push(StringMatchCandidate::new(idx, &thread.summary));
111 }
112 HistoryEntry::Context(context) => {
113 candidates.push(StringMatchCandidate::new(idx, &context.title));
114 }
115 }
116 }
117
118 const MAX_MATCHES: usize = 100;
119
120 fuzzy::match_strings(
121 &candidates,
122 &query,
123 false,
124 MAX_MATCHES,
125 &Default::default(),
126 executor,
127 )
128 .await
129 })
130 .await;
131
132 this.update(cx, |this, cx| {
133 this.matches = matches;
134 this.set_selected_index(0, cx);
135 cx.notify();
136 })
137 .log_err();
138 });
139
140 self._search_task = Some(task);
141 }
142
143 fn has_search_query(&self) -> bool {
144 !self.search_query.is_empty()
145 }
146
147 fn matched_count(&self) -> usize {
148 if self.has_search_query() {
149 self.matches.len()
150 } else {
151 self.all_entries.len()
152 }
153 }
154
155 fn get_match(&self, ix: usize) -> Option<&HistoryEntry> {
156 if self.has_search_query() {
157 self.matches
158 .get(ix)
159 .and_then(|m| self.all_entries.get(m.candidate_id))
160 } else {
161 self.all_entries.get(ix)
162 }
163 }
164
165 pub fn select_previous(
166 &mut self,
167 _: &menu::SelectPrevious,
168 _window: &mut Window,
169 cx: &mut Context<Self>,
170 ) {
171 let count = self.matched_count();
172 if count > 0 {
173 if self.selected_index == 0 {
174 self.set_selected_index(count - 1, cx);
175 } else {
176 self.set_selected_index(self.selected_index - 1, cx);
177 }
178 }
179 }
180
181 pub fn select_next(
182 &mut self,
183 _: &menu::SelectNext,
184 _window: &mut Window,
185 cx: &mut Context<Self>,
186 ) {
187 let count = self.matched_count();
188 if count > 0 {
189 if self.selected_index == count - 1 {
190 self.set_selected_index(0, cx);
191 } else {
192 self.set_selected_index(self.selected_index + 1, cx);
193 }
194 }
195 }
196
197 fn select_first(
198 &mut self,
199 _: &menu::SelectFirst,
200 _window: &mut Window,
201 cx: &mut Context<Self>,
202 ) {
203 let count = self.matched_count();
204 if count > 0 {
205 self.set_selected_index(0, cx);
206 }
207 }
208
209 fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
210 let count = self.matched_count();
211 if count > 0 {
212 self.set_selected_index(count - 1, cx);
213 }
214 }
215
216 fn set_selected_index(&mut self, index: usize, cx: &mut Context<Self>) {
217 self.selected_index = index;
218 self.scroll_handle
219 .scroll_to_item(index, ScrollStrategy::Top);
220 cx.notify();
221 }
222
223 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
224 if let Some(entry) = self.get_match(self.selected_index) {
225 let task_result = match entry {
226 HistoryEntry::Thread(thread) => self
227 .assistant_panel
228 .update(cx, move |this, cx| this.open_thread(&thread.id, window, cx)),
229 HistoryEntry::Context(context) => {
230 self.assistant_panel.update(cx, move |this, cx| {
231 this.open_saved_prompt_editor(context.path.clone(), window, cx)
232 })
233 }
234 };
235
236 if let Some(task) = task_result.log_err() {
237 task.detach_and_log_err(cx);
238 };
239
240 cx.notify();
241 }
242 }
243
244 fn remove_selected_thread(
245 &mut self,
246 _: &RemoveSelectedThread,
247 _window: &mut Window,
248 cx: &mut Context<Self>,
249 ) {
250 if let Some(entry) = self.get_match(self.selected_index) {
251 let task_result = match entry {
252 HistoryEntry::Thread(thread) => self
253 .assistant_panel
254 .update(cx, |this, cx| this.delete_thread(&thread.id, cx)),
255 HistoryEntry::Context(context) => self
256 .assistant_panel
257 .update(cx, |this, cx| this.delete_context(context.path.clone(), cx)),
258 };
259
260 if let Some(task) = task_result.log_err() {
261 cx.spawn(async move |this, cx| {
262 task.await?;
263 this.update(cx, |this, cx| this.update_all_entries(cx))
264 })
265 .detach_and_log_err(cx);
266 };
267
268 cx.notify();
269 }
270 }
271}
272
273impl Focusable for ThreadHistory {
274 fn focus_handle(&self, cx: &App) -> FocusHandle {
275 self.search_editor.focus_handle(cx)
276 }
277}
278
279impl Render for ThreadHistory {
280 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
281 let selected_index = self.selected_index;
282
283 v_flex()
284 .key_context("ThreadHistory")
285 .size_full()
286 .on_action(cx.listener(Self::select_previous))
287 .on_action(cx.listener(Self::select_next))
288 .on_action(cx.listener(Self::select_first))
289 .on_action(cx.listener(Self::select_last))
290 .on_action(cx.listener(Self::confirm))
291 .on_action(cx.listener(Self::remove_selected_thread))
292 .when(!self.all_entries.is_empty(), |parent| {
293 parent.child(
294 h_flex()
295 .h(px(41.)) // Match the toolbar perfectly
296 .w_full()
297 .py_1()
298 .px_2()
299 .gap_2()
300 .justify_between()
301 .border_b_1()
302 .border_color(cx.theme().colors().border)
303 .child(
304 Icon::new(IconName::MagnifyingGlass)
305 .color(Color::Muted)
306 .size(IconSize::Small),
307 )
308 .child(self.search_editor.clone()),
309 )
310 })
311 .child({
312 let view = v_flex().overflow_hidden().flex_grow();
313
314 if self.all_entries.is_empty() {
315 view.justify_center()
316 .child(
317 h_flex().w_full().justify_center().child(
318 Label::new("You don't have any past threads yet.")
319 .size(LabelSize::Small),
320 ),
321 )
322 } else if self.has_search_query() && self.matches.is_empty() {
323 view.justify_center().child(
324 h_flex().w_full().justify_center().child(
325 Label::new("No threads match your search.").size(LabelSize::Small),
326 ),
327 )
328 } else {
329 view.p_1().child(
330 uniform_list(
331 cx.entity().clone(),
332 "thread-history",
333 self.matched_count(),
334 move |history, range, _window, _cx| {
335 let range_start = range.start;
336 let assistant_panel = history.assistant_panel.clone();
337
338 let render_item = |index: usize,
339 entry: &HistoryEntry,
340 highlight_positions: Vec<usize>|
341 -> Div {
342 h_flex().w_full().pb_1().child(match entry {
343 HistoryEntry::Thread(thread) => PastThread::new(
344 thread.clone(),
345 assistant_panel.clone(),
346 selected_index == index + range_start,
347 highlight_positions,
348 )
349 .into_any_element(),
350 HistoryEntry::Context(context) => PastContext::new(
351 context.clone(),
352 assistant_panel.clone(),
353 selected_index == index + range_start,
354 highlight_positions,
355 )
356 .into_any_element(),
357 })
358 };
359
360 if history.has_search_query() {
361 history.matches[range]
362 .iter()
363 .enumerate()
364 .filter_map(|(index, m)| {
365 history.all_entries.get(m.candidate_id).map(|entry| {
366 render_item(index, entry, m.positions.clone())
367 })
368 })
369 .collect()
370 } else {
371 history.all_entries[range]
372 .iter()
373 .enumerate()
374 .map(|(index, entry)| render_item(index, entry, vec![]))
375 .collect()
376 }
377 },
378 )
379 .track_scroll(self.scroll_handle.clone())
380 .flex_grow(),
381 )
382 }
383 })
384 }
385}
386
387#[derive(IntoElement)]
388pub struct PastThread {
389 thread: SerializedThreadMetadata,
390 assistant_panel: WeakEntity<AssistantPanel>,
391 selected: bool,
392 highlight_positions: Vec<usize>,
393}
394
395impl PastThread {
396 pub fn new(
397 thread: SerializedThreadMetadata,
398 assistant_panel: WeakEntity<AssistantPanel>,
399 selected: bool,
400 highlight_positions: Vec<usize>,
401 ) -> Self {
402 Self {
403 thread,
404 assistant_panel,
405 selected,
406 highlight_positions,
407 }
408 }
409}
410
411impl RenderOnce for PastThread {
412 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
413 let summary = self.thread.summary;
414
415 let thread_timestamp = time_format::format_localized_timestamp(
416 OffsetDateTime::from_unix_timestamp(self.thread.updated_at.timestamp()).unwrap(),
417 OffsetDateTime::now_utc(),
418 self.assistant_panel
419 .update(cx, |this, _cx| this.local_timezone())
420 .unwrap_or(UtcOffset::UTC),
421 time_format::TimestampFormat::EnhancedAbsolute,
422 );
423
424 ListItem::new(SharedString::from(self.thread.id.to_string()))
425 .rounded()
426 .toggle_state(self.selected)
427 .spacing(ListItemSpacing::Sparse)
428 .start_slot(
429 div().max_w_4_5().child(
430 HighlightedLabel::new(summary, self.highlight_positions)
431 .size(LabelSize::Small)
432 .truncate(),
433 ),
434 )
435 .end_slot(
436 h_flex()
437 .gap_1p5()
438 .child(
439 Label::new("Thread")
440 .color(Color::Muted)
441 .size(LabelSize::XSmall),
442 )
443 .child(
444 div()
445 .size(px(3.))
446 .rounded_full()
447 .bg(cx.theme().colors().text_disabled),
448 )
449 .child(
450 Label::new(thread_timestamp)
451 .color(Color::Muted)
452 .size(LabelSize::XSmall),
453 )
454 .child(
455 IconButton::new("delete", IconName::TrashAlt)
456 .shape(IconButtonShape::Square)
457 .icon_size(IconSize::XSmall)
458 .tooltip(move |window, cx| {
459 Tooltip::for_action(
460 "Delete Thread",
461 &RemoveSelectedThread,
462 window,
463 cx,
464 )
465 })
466 .on_click({
467 let assistant_panel = self.assistant_panel.clone();
468 let id = self.thread.id.clone();
469 move |_event, _window, cx| {
470 assistant_panel
471 .update(cx, |this, cx| {
472 this.delete_thread(&id, cx).detach_and_log_err(cx);
473 })
474 .ok();
475 }
476 }),
477 ),
478 )
479 .on_click({
480 let assistant_panel = self.assistant_panel.clone();
481 let id = self.thread.id.clone();
482 move |_event, window, cx| {
483 assistant_panel
484 .update(cx, |this, cx| {
485 this.open_thread(&id, window, cx).detach_and_log_err(cx);
486 })
487 .ok();
488 }
489 })
490 }
491}
492
493#[derive(IntoElement)]
494pub struct PastContext {
495 context: SavedContextMetadata,
496 assistant_panel: WeakEntity<AssistantPanel>,
497 selected: bool,
498 highlight_positions: Vec<usize>,
499}
500
501impl PastContext {
502 pub fn new(
503 context: SavedContextMetadata,
504 assistant_panel: WeakEntity<AssistantPanel>,
505 selected: bool,
506 highlight_positions: Vec<usize>,
507 ) -> Self {
508 Self {
509 context,
510 assistant_panel,
511 selected,
512 highlight_positions,
513 }
514 }
515}
516
517impl RenderOnce for PastContext {
518 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
519 let summary = self.context.title;
520 let context_timestamp = time_format::format_localized_timestamp(
521 OffsetDateTime::from_unix_timestamp(self.context.mtime.timestamp()).unwrap(),
522 OffsetDateTime::now_utc(),
523 self.assistant_panel
524 .update(cx, |this, _cx| this.local_timezone())
525 .unwrap_or(UtcOffset::UTC),
526 time_format::TimestampFormat::EnhancedAbsolute,
527 );
528
529 ListItem::new(SharedString::from(
530 self.context.path.to_string_lossy().to_string(),
531 ))
532 .rounded()
533 .toggle_state(self.selected)
534 .spacing(ListItemSpacing::Sparse)
535 .start_slot(
536 div().max_w_4_5().child(
537 HighlightedLabel::new(summary, self.highlight_positions)
538 .size(LabelSize::Small)
539 .truncate(),
540 ),
541 )
542 .end_slot(
543 h_flex()
544 .gap_1p5()
545 .child(
546 Label::new("Prompt Editor")
547 .color(Color::Muted)
548 .size(LabelSize::XSmall),
549 )
550 .child(
551 div()
552 .size(px(3.))
553 .rounded_full()
554 .bg(cx.theme().colors().text_disabled),
555 )
556 .child(
557 Label::new(context_timestamp)
558 .color(Color::Muted)
559 .size(LabelSize::XSmall),
560 )
561 .child(
562 IconButton::new("delete", IconName::TrashAlt)
563 .shape(IconButtonShape::Square)
564 .icon_size(IconSize::XSmall)
565 .tooltip(Tooltip::text("Delete Prompt Editor"))
566 .on_click({
567 let assistant_panel = self.assistant_panel.clone();
568 let path = self.context.path.clone();
569 move |_event, _window, cx| {
570 assistant_panel
571 .update(cx, |this, cx| {
572 this.delete_context(path.clone(), cx)
573 .detach_and_log_err(cx);
574 })
575 .ok();
576 }
577 }),
578 ),
579 )
580 .on_click({
581 let assistant_panel = self.assistant_panel.clone();
582 let path = self.context.path.clone();
583 move |_event, window, cx| {
584 assistant_panel
585 .update(cx, |this, cx| {
586 this.open_saved_prompt_editor(path.clone(), window, cx)
587 .detach_and_log_err(cx);
588 })
589 .ok();
590 }
591 })
592 }
593}