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}