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