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}