thread_history.rs

  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}