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                task.detach_and_log_err(cx);
262            };
263
264            cx.notify();
265        }
266    }
267}
268
269impl Focusable for ThreadHistory {
270    fn focus_handle(&self, cx: &App) -> FocusHandle {
271        self.search_editor.focus_handle(cx)
272    }
273}
274
275impl Render for ThreadHistory {
276    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
277        let selected_index = self.selected_index;
278
279        v_flex()
280            .key_context("ThreadHistory")
281            .size_full()
282            .on_action(cx.listener(Self::select_previous))
283            .on_action(cx.listener(Self::select_next))
284            .on_action(cx.listener(Self::select_first))
285            .on_action(cx.listener(Self::select_last))
286            .on_action(cx.listener(Self::confirm))
287            .on_action(cx.listener(Self::remove_selected_thread))
288            .when(!self.all_entries.is_empty(), |parent| {
289                parent.child(
290                    h_flex()
291                        .h(px(41.)) // Match the toolbar perfectly
292                        .w_full()
293                        .py_1()
294                        .px_2()
295                        .gap_2()
296                        .justify_between()
297                        .border_b_1()
298                        .border_color(cx.theme().colors().border)
299                        .child(
300                            Icon::new(IconName::MagnifyingGlass)
301                                .color(Color::Muted)
302                                .size(IconSize::Small),
303                        )
304                        .child(self.search_editor.clone()),
305                )
306            })
307            .child({
308                let view = v_flex().overflow_hidden().flex_grow();
309
310                if self.all_entries.is_empty() {
311                    view.justify_center()
312                        .child(
313                            h_flex().w_full().justify_center().child(
314                                Label::new("You don't have any past threads yet.")
315                                    .size(LabelSize::Small),
316                            ),
317                        )
318                } else if self.has_search_query() && self.matches.is_empty() {
319                    view.justify_center().child(
320                        h_flex().w_full().justify_center().child(
321                            Label::new("No threads match your search.").size(LabelSize::Small),
322                        ),
323                    )
324                } else {
325                    view.p_1().child(
326                        uniform_list(
327                            cx.entity().clone(),
328                            "thread-history",
329                            self.matched_count(),
330                            move |history, range, _window, _cx| {
331                                let range_start = range.start;
332                                let assistant_panel = history.assistant_panel.clone();
333
334                                let render_item = |index: usize,
335                                                   entry: &HistoryEntry,
336                                                   highlight_positions: Vec<usize>|
337                                 -> Div {
338                                    h_flex().w_full().pb_1().child(match entry {
339                                        HistoryEntry::Thread(thread) => PastThread::new(
340                                            thread.clone(),
341                                            assistant_panel.clone(),
342                                            selected_index == index + range_start,
343                                            highlight_positions,
344                                        )
345                                        .into_any_element(),
346                                        HistoryEntry::Context(context) => PastContext::new(
347                                            context.clone(),
348                                            assistant_panel.clone(),
349                                            selected_index == index + range_start,
350                                            highlight_positions,
351                                        )
352                                        .into_any_element(),
353                                    })
354                                };
355
356                                if history.has_search_query() {
357                                    history.matches[range]
358                                        .iter()
359                                        .enumerate()
360                                        .filter_map(|(index, m)| {
361                                            history.all_entries.get(m.candidate_id).map(|entry| {
362                                                render_item(index, entry, m.positions.clone())
363                                            })
364                                        })
365                                        .collect()
366                                } else {
367                                    history.all_entries[range]
368                                        .iter()
369                                        .enumerate()
370                                        .map(|(index, entry)| render_item(index, entry, vec![]))
371                                        .collect()
372                                }
373                            },
374                        )
375                        .track_scroll(self.scroll_handle.clone())
376                        .flex_grow(),
377                    )
378                }
379            })
380    }
381}
382
383#[derive(IntoElement)]
384pub struct PastThread {
385    thread: SerializedThreadMetadata,
386    assistant_panel: WeakEntity<AssistantPanel>,
387    selected: bool,
388    highlight_positions: Vec<usize>,
389}
390
391impl PastThread {
392    pub fn new(
393        thread: SerializedThreadMetadata,
394        assistant_panel: WeakEntity<AssistantPanel>,
395        selected: bool,
396        highlight_positions: Vec<usize>,
397    ) -> Self {
398        Self {
399            thread,
400            assistant_panel,
401            selected,
402            highlight_positions,
403        }
404    }
405}
406
407impl RenderOnce for PastThread {
408    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
409        let summary = self.thread.summary;
410
411        let thread_timestamp = time_format::format_localized_timestamp(
412            OffsetDateTime::from_unix_timestamp(self.thread.updated_at.timestamp()).unwrap(),
413            OffsetDateTime::now_utc(),
414            self.assistant_panel
415                .update(cx, |this, _cx| this.local_timezone())
416                .unwrap_or(UtcOffset::UTC),
417            time_format::TimestampFormat::EnhancedAbsolute,
418        );
419
420        ListItem::new(SharedString::from(self.thread.id.to_string()))
421            .rounded()
422            .toggle_state(self.selected)
423            .spacing(ListItemSpacing::Sparse)
424            .start_slot(
425                div().max_w_4_5().child(
426                    HighlightedLabel::new(summary, self.highlight_positions)
427                        .size(LabelSize::Small)
428                        .truncate(),
429                ),
430            )
431            .end_slot(
432                h_flex()
433                    .gap_1p5()
434                    .child(
435                        Label::new("Thread")
436                            .color(Color::Muted)
437                            .size(LabelSize::XSmall),
438                    )
439                    .child(
440                        div()
441                            .size(px(3.))
442                            .rounded_full()
443                            .bg(cx.theme().colors().text_disabled),
444                    )
445                    .child(
446                        Label::new(thread_timestamp)
447                            .color(Color::Muted)
448                            .size(LabelSize::XSmall),
449                    )
450                    .child(
451                        IconButton::new("delete", IconName::TrashAlt)
452                            .shape(IconButtonShape::Square)
453                            .icon_size(IconSize::XSmall)
454                            .tooltip(move |window, cx| {
455                                Tooltip::for_action(
456                                    "Delete Thread",
457                                    &RemoveSelectedThread,
458                                    window,
459                                    cx,
460                                )
461                            })
462                            .on_click({
463                                let assistant_panel = self.assistant_panel.clone();
464                                let id = self.thread.id.clone();
465                                move |_event, _window, cx| {
466                                    assistant_panel
467                                        .update(cx, |this, cx| {
468                                            this.delete_thread(&id, cx).detach_and_log_err(cx);
469                                        })
470                                        .ok();
471                                }
472                            }),
473                    ),
474            )
475            .on_click({
476                let assistant_panel = self.assistant_panel.clone();
477                let id = self.thread.id.clone();
478                move |_event, window, cx| {
479                    assistant_panel
480                        .update(cx, |this, cx| {
481                            this.open_thread(&id, window, cx).detach_and_log_err(cx);
482                        })
483                        .ok();
484                }
485            })
486    }
487}
488
489#[derive(IntoElement)]
490pub struct PastContext {
491    context: SavedContextMetadata,
492    assistant_panel: WeakEntity<AssistantPanel>,
493    selected: bool,
494    highlight_positions: Vec<usize>,
495}
496
497impl PastContext {
498    pub fn new(
499        context: SavedContextMetadata,
500        assistant_panel: WeakEntity<AssistantPanel>,
501        selected: bool,
502        highlight_positions: Vec<usize>,
503    ) -> Self {
504        Self {
505            context,
506            assistant_panel,
507            selected,
508            highlight_positions,
509        }
510    }
511}
512
513impl RenderOnce for PastContext {
514    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
515        let summary = self.context.title;
516        let context_timestamp = time_format::format_localized_timestamp(
517            OffsetDateTime::from_unix_timestamp(self.context.mtime.timestamp()).unwrap(),
518            OffsetDateTime::now_utc(),
519            self.assistant_panel
520                .update(cx, |this, _cx| this.local_timezone())
521                .unwrap_or(UtcOffset::UTC),
522            time_format::TimestampFormat::EnhancedAbsolute,
523        );
524
525        ListItem::new(SharedString::from(
526            self.context.path.to_string_lossy().to_string(),
527        ))
528        .rounded()
529        .toggle_state(self.selected)
530        .spacing(ListItemSpacing::Sparse)
531        .start_slot(
532            div().max_w_4_5().child(
533                HighlightedLabel::new(summary, self.highlight_positions)
534                    .size(LabelSize::Small)
535                    .truncate(),
536            ),
537        )
538        .end_slot(
539            h_flex()
540                .gap_1p5()
541                .child(
542                    Label::new("Prompt Editor")
543                        .color(Color::Muted)
544                        .size(LabelSize::XSmall),
545                )
546                .child(
547                    div()
548                        .size(px(3.))
549                        .rounded_full()
550                        .bg(cx.theme().colors().text_disabled),
551                )
552                .child(
553                    Label::new(context_timestamp)
554                        .color(Color::Muted)
555                        .size(LabelSize::XSmall),
556                )
557                .child(
558                    IconButton::new("delete", IconName::TrashAlt)
559                        .shape(IconButtonShape::Square)
560                        .icon_size(IconSize::XSmall)
561                        .tooltip(move |window, cx| {
562                            Tooltip::for_action(
563                                "Delete Prompt Editor",
564                                &RemoveSelectedThread,
565                                window,
566                                cx,
567                            )
568                        })
569                        .on_click({
570                            let assistant_panel = self.assistant_panel.clone();
571                            let path = self.context.path.clone();
572                            move |_event, _window, cx| {
573                                assistant_panel
574                                    .update(cx, |this, cx| {
575                                        this.delete_context(path.clone(), cx)
576                                            .detach_and_log_err(cx);
577                                    })
578                                    .ok();
579                            }
580                        }),
581                ),
582        )
583        .on_click({
584            let assistant_panel = self.assistant_panel.clone();
585            let path = self.context.path.clone();
586            move |_event, window, cx| {
587                assistant_panel
588                    .update(cx, |this, cx| {
589                        this.open_saved_prompt_editor(path.clone(), window, cx)
590                            .detach_and_log_err(cx);
591                    })
592                    .ok();
593            }
594        })
595    }
596}