thread_history.rs

  1use assistant_context_editor::SavedContextMetadata;
  2use gpui::{
  3    App, Entity, FocusHandle, Focusable, ScrollStrategy, UniformListScrollHandle, WeakEntity,
  4    uniform_list,
  5};
  6use time::{OffsetDateTime, UtcOffset};
  7use ui::{IconButtonShape, ListItem, ListItemSpacing, Tooltip, prelude::*};
  8
  9use crate::history_store::{HistoryEntry, HistoryStore};
 10use crate::thread_store::SerializedThreadMetadata;
 11use crate::{AssistantPanel, RemoveSelectedThread};
 12
 13pub struct ThreadHistory {
 14    focus_handle: FocusHandle,
 15    assistant_panel: WeakEntity<AssistantPanel>,
 16    history_store: Entity<HistoryStore>,
 17    scroll_handle: UniformListScrollHandle,
 18    selected_index: usize,
 19}
 20
 21impl ThreadHistory {
 22    pub(crate) fn new(
 23        assistant_panel: WeakEntity<AssistantPanel>,
 24        history_store: Entity<HistoryStore>,
 25        cx: &mut Context<Self>,
 26    ) -> Self {
 27        Self {
 28            focus_handle: cx.focus_handle(),
 29            assistant_panel,
 30            history_store,
 31            scroll_handle: UniformListScrollHandle::default(),
 32            selected_index: 0,
 33        }
 34    }
 35
 36    pub fn select_previous(
 37        &mut self,
 38        _: &menu::SelectPrevious,
 39        window: &mut Window,
 40        cx: &mut Context<Self>,
 41    ) {
 42        let count = self
 43            .history_store
 44            .update(cx, |this, cx| this.entry_count(cx));
 45        if count > 0 {
 46            if self.selected_index == 0 {
 47                self.set_selected_index(count - 1, window, cx);
 48            } else {
 49                self.set_selected_index(self.selected_index - 1, window, cx);
 50            }
 51        }
 52    }
 53
 54    pub fn select_next(
 55        &mut self,
 56        _: &menu::SelectNext,
 57        window: &mut Window,
 58        cx: &mut Context<Self>,
 59    ) {
 60        let count = self
 61            .history_store
 62            .update(cx, |this, cx| this.entry_count(cx));
 63        if count > 0 {
 64            if self.selected_index == count - 1 {
 65                self.set_selected_index(0, window, cx);
 66            } else {
 67                self.set_selected_index(self.selected_index + 1, window, cx);
 68            }
 69        }
 70    }
 71
 72    fn select_first(&mut self, _: &menu::SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
 73        let count = self
 74            .history_store
 75            .update(cx, |this, cx| this.entry_count(cx));
 76        if count > 0 {
 77            self.set_selected_index(0, window, cx);
 78        }
 79    }
 80
 81    fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context<Self>) {
 82        let count = self
 83            .history_store
 84            .update(cx, |this, cx| this.entry_count(cx));
 85        if count > 0 {
 86            self.set_selected_index(count - 1, window, cx);
 87        }
 88    }
 89
 90    fn set_selected_index(&mut self, index: usize, _window: &mut Window, cx: &mut Context<Self>) {
 91        self.selected_index = index;
 92        self.scroll_handle
 93            .scroll_to_item(index, ScrollStrategy::Top);
 94        cx.notify();
 95    }
 96
 97    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
 98        let entries = self.history_store.update(cx, |this, cx| this.entries(cx));
 99
100        if let Some(entry) = entries.get(self.selected_index) {
101            match entry {
102                HistoryEntry::Thread(thread) => {
103                    self.assistant_panel
104                        .update(cx, move |this, cx| this.open_thread(&thread.id, window, cx))
105                        .ok();
106                }
107                HistoryEntry::Context(context) => {
108                    self.assistant_panel
109                        .update(cx, move |this, cx| {
110                            this.open_saved_prompt_editor(context.path.clone(), window, cx)
111                        })
112                        .ok();
113                }
114            }
115
116            cx.notify();
117        }
118    }
119
120    fn remove_selected_thread(
121        &mut self,
122        _: &RemoveSelectedThread,
123        _window: &mut Window,
124        cx: &mut Context<Self>,
125    ) {
126        let entries = self.history_store.update(cx, |this, cx| this.entries(cx));
127
128        if let Some(entry) = entries.get(self.selected_index) {
129            match entry {
130                HistoryEntry::Thread(thread) => {
131                    self.assistant_panel
132                        .update(cx, |this, cx| {
133                            this.delete_thread(&thread.id, cx);
134                        })
135                        .ok();
136                }
137                HistoryEntry::Context(context) => {
138                    self.assistant_panel
139                        .update(cx, |this, cx| {
140                            this.delete_context(context.path.clone(), cx);
141                        })
142                        .ok();
143                }
144            }
145
146            cx.notify();
147        }
148    }
149}
150
151impl Focusable for ThreadHistory {
152    fn focus_handle(&self, _cx: &App) -> FocusHandle {
153        self.focus_handle.clone()
154    }
155}
156
157impl Render for ThreadHistory {
158    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
159        let history_entries = self.history_store.update(cx, |this, cx| this.entries(cx));
160        let selected_index = self.selected_index;
161
162        v_flex()
163            .id("thread-history-container")
164            .key_context("ThreadHistory")
165            .track_focus(&self.focus_handle)
166            .overflow_y_scroll()
167            .size_full()
168            .p_1()
169            .on_action(cx.listener(Self::select_previous))
170            .on_action(cx.listener(Self::select_next))
171            .on_action(cx.listener(Self::select_first))
172            .on_action(cx.listener(Self::select_last))
173            .on_action(cx.listener(Self::confirm))
174            .on_action(cx.listener(Self::remove_selected_thread))
175            .map(|history| {
176                if history_entries.is_empty() {
177                    history
178                        .justify_center()
179                        .child(
180                            h_flex().w_full().justify_center().child(
181                                Label::new("You don't have any past threads yet.")
182                                    .size(LabelSize::Small),
183                            ),
184                        )
185                } else {
186                    history.child(
187                        uniform_list(
188                            cx.entity().clone(),
189                            "thread-history",
190                            history_entries.len(),
191                            move |history, range, _window, _cx| {
192                                history_entries[range]
193                                    .iter()
194                                    .enumerate()
195                                    .map(|(index, entry)| {
196                                        h_flex().w_full().pb_1().child(match entry {
197                                            HistoryEntry::Thread(thread) => PastThread::new(
198                                                thread.clone(),
199                                                history.assistant_panel.clone(),
200                                                selected_index == index,
201                                            )
202                                            .into_any_element(),
203                                            HistoryEntry::Context(context) => PastContext::new(
204                                                context.clone(),
205                                                history.assistant_panel.clone(),
206                                                selected_index == index,
207                                            )
208                                            .into_any_element(),
209                                        })
210                                    })
211                                    .collect()
212                            },
213                        )
214                        .track_scroll(self.scroll_handle.clone())
215                        .flex_grow(),
216                    )
217                }
218            })
219    }
220}
221
222#[derive(IntoElement)]
223pub struct PastThread {
224    thread: SerializedThreadMetadata,
225    assistant_panel: WeakEntity<AssistantPanel>,
226    selected: bool,
227}
228
229impl PastThread {
230    pub fn new(
231        thread: SerializedThreadMetadata,
232        assistant_panel: WeakEntity<AssistantPanel>,
233        selected: bool,
234    ) -> Self {
235        Self {
236            thread,
237            assistant_panel,
238            selected,
239        }
240    }
241}
242
243impl RenderOnce for PastThread {
244    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
245        let summary = self.thread.summary;
246
247        let thread_timestamp = time_format::format_localized_timestamp(
248            OffsetDateTime::from_unix_timestamp(self.thread.updated_at.timestamp()).unwrap(),
249            OffsetDateTime::now_utc(),
250            self.assistant_panel
251                .update(cx, |this, _cx| this.local_timezone())
252                .unwrap_or(UtcOffset::UTC),
253            time_format::TimestampFormat::EnhancedAbsolute,
254        );
255
256        ListItem::new(SharedString::from(self.thread.id.to_string()))
257            .rounded()
258            .toggle_state(self.selected)
259            .spacing(ListItemSpacing::Sparse)
260            .start_slot(
261                div()
262                    .max_w_4_5()
263                    .child(Label::new(summary).size(LabelSize::Small).truncate()),
264            )
265            .end_slot(
266                h_flex()
267                    .gap_1p5()
268                    .child(
269                        Label::new("Thread")
270                            .color(Color::Muted)
271                            .size(LabelSize::XSmall),
272                    )
273                    .child(
274                        div()
275                            .size(px(3.))
276                            .rounded_full()
277                            .bg(cx.theme().colors().text_disabled),
278                    )
279                    .child(
280                        Label::new(thread_timestamp)
281                            .color(Color::Muted)
282                            .size(LabelSize::XSmall),
283                    )
284                    .child(
285                        IconButton::new("delete", IconName::TrashAlt)
286                            .shape(IconButtonShape::Square)
287                            .icon_size(IconSize::XSmall)
288                            .tooltip(Tooltip::text("Delete Thread"))
289                            .on_click({
290                                let assistant_panel = self.assistant_panel.clone();
291                                let id = self.thread.id.clone();
292                                move |_event, _window, cx| {
293                                    assistant_panel
294                                        .update(cx, |this, cx| {
295                                            this.delete_thread(&id, cx);
296                                        })
297                                        .ok();
298                                }
299                            }),
300                    ),
301            )
302            .on_click({
303                let assistant_panel = self.assistant_panel.clone();
304                let id = self.thread.id.clone();
305                move |_event, window, cx| {
306                    assistant_panel
307                        .update(cx, |this, cx| {
308                            this.open_thread(&id, window, cx).detach_and_log_err(cx);
309                        })
310                        .ok();
311                }
312            })
313    }
314}
315
316#[derive(IntoElement)]
317pub struct PastContext {
318    context: SavedContextMetadata,
319    assistant_panel: WeakEntity<AssistantPanel>,
320    selected: bool,
321}
322
323impl PastContext {
324    pub fn new(
325        context: SavedContextMetadata,
326        assistant_panel: WeakEntity<AssistantPanel>,
327        selected: bool,
328    ) -> Self {
329        Self {
330            context,
331            assistant_panel,
332            selected,
333        }
334    }
335}
336
337impl RenderOnce for PastContext {
338    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
339        let summary = self.context.title;
340
341        let context_timestamp = time_format::format_localized_timestamp(
342            OffsetDateTime::from_unix_timestamp(self.context.mtime.timestamp()).unwrap(),
343            OffsetDateTime::now_utc(),
344            self.assistant_panel
345                .update(cx, |this, _cx| this.local_timezone())
346                .unwrap_or(UtcOffset::UTC),
347            time_format::TimestampFormat::EnhancedAbsolute,
348        );
349
350        ListItem::new(SharedString::from(
351            self.context.path.to_string_lossy().to_string(),
352        ))
353        .rounded()
354        .toggle_state(self.selected)
355        .spacing(ListItemSpacing::Sparse)
356        .start_slot(
357            div()
358                .max_w_4_5()
359                .child(Label::new(summary).size(LabelSize::Small).truncate()),
360        )
361        .end_slot(
362            h_flex()
363                .gap_1p5()
364                .child(
365                    Label::new("Prompt Editor")
366                        .color(Color::Muted)
367                        .size(LabelSize::XSmall),
368                )
369                .child(
370                    div()
371                        .size(px(3.))
372                        .rounded_full()
373                        .bg(cx.theme().colors().text_disabled),
374                )
375                .child(
376                    Label::new(context_timestamp)
377                        .color(Color::Muted)
378                        .size(LabelSize::XSmall),
379                )
380                .child(
381                    IconButton::new("delete", IconName::TrashAlt)
382                        .shape(IconButtonShape::Square)
383                        .icon_size(IconSize::XSmall)
384                        .tooltip(Tooltip::text("Delete Prompt Editor"))
385                        .on_click({
386                            let assistant_panel = self.assistant_panel.clone();
387                            let path = self.context.path.clone();
388                            move |_event, _window, cx| {
389                                assistant_panel
390                                    .update(cx, |this, cx| {
391                                        this.delete_context(path.clone(), cx);
392                                    })
393                                    .ok();
394                            }
395                        }),
396                ),
397        )
398        .on_click({
399            let assistant_panel = self.assistant_panel.clone();
400            let path = self.context.path.clone();
401            move |_event, window, cx| {
402                assistant_panel
403                    .update(cx, |this, cx| {
404                        this.open_saved_prompt_editor(path.clone(), window, cx)
405                            .detach_and_log_err(cx);
406                    })
407                    .ok();
408            }
409        })
410    }
411}