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