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_timestamp)
436                            .color(Color::Muted)
437                            .size(LabelSize::XSmall),
438                    )
439                    .child(
440                        IconButton::new("delete", IconName::TrashAlt)
441                            .shape(IconButtonShape::Square)
442                            .icon_size(IconSize::XSmall)
443                            .tooltip(move |window, cx| {
444                                Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
445                            })
446                            .on_click({
447                                let assistant_panel = self.assistant_panel.clone();
448                                let id = self.thread.id.clone();
449                                move |_event, _window, cx| {
450                                    assistant_panel
451                                        .update(cx, |this, cx| {
452                                            this.delete_thread(&id, cx).detach_and_log_err(cx);
453                                        })
454                                        .ok();
455                                }
456                            }),
457                    ),
458            )
459            .on_click({
460                let assistant_panel = self.assistant_panel.clone();
461                let id = self.thread.id.clone();
462                move |_event, window, cx| {
463                    assistant_panel
464                        .update(cx, |this, cx| {
465                            this.open_thread(&id, window, cx).detach_and_log_err(cx);
466                        })
467                        .ok();
468                }
469            })
470    }
471}
472
473#[derive(IntoElement)]
474pub struct PastContext {
475    context: SavedContextMetadata,
476    assistant_panel: WeakEntity<AssistantPanel>,
477    selected: bool,
478    highlight_positions: Vec<usize>,
479}
480
481impl PastContext {
482    pub fn new(
483        context: SavedContextMetadata,
484        assistant_panel: WeakEntity<AssistantPanel>,
485        selected: bool,
486        highlight_positions: Vec<usize>,
487    ) -> Self {
488        Self {
489            context,
490            assistant_panel,
491            selected,
492            highlight_positions,
493        }
494    }
495}
496
497impl RenderOnce for PastContext {
498    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
499        let summary = self.context.title;
500        let context_timestamp = time_format::format_localized_timestamp(
501            OffsetDateTime::from_unix_timestamp(self.context.mtime.timestamp()).unwrap(),
502            OffsetDateTime::now_utc(),
503            self.assistant_panel
504                .update(cx, |this, _cx| this.local_timezone())
505                .unwrap_or(UtcOffset::UTC),
506            time_format::TimestampFormat::EnhancedAbsolute,
507        );
508
509        ListItem::new(SharedString::from(
510            self.context.path.to_string_lossy().to_string(),
511        ))
512        .rounded()
513        .toggle_state(self.selected)
514        .spacing(ListItemSpacing::Sparse)
515        .start_slot(
516            div().max_w_4_5().child(
517                HighlightedLabel::new(summary, self.highlight_positions)
518                    .size(LabelSize::Small)
519                    .truncate(),
520            ),
521        )
522        .end_slot(
523            h_flex()
524                .gap_1p5()
525                .child(
526                    Label::new(context_timestamp)
527                        .color(Color::Muted)
528                        .size(LabelSize::XSmall),
529                )
530                .child(
531                    IconButton::new("delete", IconName::TrashAlt)
532                        .shape(IconButtonShape::Square)
533                        .icon_size(IconSize::XSmall)
534                        .tooltip(move |window, cx| {
535                            Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
536                        })
537                        .on_click({
538                            let assistant_panel = self.assistant_panel.clone();
539                            let path = self.context.path.clone();
540                            move |_event, _window, cx| {
541                                assistant_panel
542                                    .update(cx, |this, cx| {
543                                        this.delete_context(path.clone(), cx)
544                                            .detach_and_log_err(cx);
545                                    })
546                                    .ok();
547                            }
548                        }),
549                ),
550        )
551        .on_click({
552            let assistant_panel = self.assistant_panel.clone();
553            let path = self.context.path.clone();
554            move |_event, window, cx| {
555                assistant_panel
556                    .update(cx, |this, cx| {
557                        this.open_saved_prompt_editor(path.clone(), window, cx)
558                            .detach_and_log_err(cx);
559                    })
560                    .ok();
561            }
562        })
563    }
564}