From 3d48efad6756313a19fab69c3c561337e0fa32a5 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 4 Apr 2025 10:09:21 -0300 Subject: [PATCH] agent: Add search to Thread History (#28085) ![CleanShot 2025-04-04 at 09 45 47@2x](https://github.com/user-attachments/assets/a8ec4086-f71e-4ff4-a5b3-4eb5d4c48294) Release Notes: - agent: Add search box to thread history --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: Danilo Leal --- crates/agent/src/assistant_panel.rs | 6 +- crates/agent/src/history_store.rs | 15 +- crates/agent/src/thread_history.rs | 353 +++++++++++++++++++++------- 3 files changed, 276 insertions(+), 98 deletions(-) diff --git a/crates/agent/src/assistant_panel.rs b/crates/agent/src/assistant_panel.rs index bc70f81f3e..e11ac31586 100644 --- a/crates/agent/src/assistant_panel.rs +++ b/crates/agent/src/assistant_panel.rs @@ -228,7 +228,7 @@ impl AssistantPanel { ) .unwrap(), history_store: history_store.clone(), - history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, cx)), + history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)), assistant_dropdown_menu_handle: PopoverMenuHandle::default(), width: None, height: None, @@ -1134,11 +1134,11 @@ impl AssistantPanel { // TODO: Add keyboard navigation. match entry { HistoryEntry::Thread(thread) => { - PastThread::new(thread, cx.entity().downgrade(), false) + PastThread::new(thread, cx.entity().downgrade(), false, vec![]) .into_any_element() } HistoryEntry::Context(context) => { - PastContext::new(context, cx.entity().downgrade(), false) + PastContext::new(context, cx.entity().downgrade(), false, vec![]) .into_any_element() } } diff --git a/crates/agent/src/history_store.rs b/crates/agent/src/history_store.rs index 9fd3b5a098..3947a7dc0e 100644 --- a/crates/agent/src/history_store.rs +++ b/crates/agent/src/history_store.rs @@ -4,6 +4,7 @@ use gpui::{Entity, prelude::*}; use crate::thread_store::{SerializedThreadMetadata, ThreadStore}; +#[derive(Debug)] pub enum HistoryEntry { Thread(SerializedThreadMetadata), Context(SavedContextMetadata), @@ -21,25 +22,27 @@ impl HistoryEntry { pub struct HistoryStore { thread_store: Entity, context_store: Entity, + _subscriptions: Vec, } impl HistoryStore { pub fn new( thread_store: Entity, context_store: Entity, - _cx: &mut Context, + cx: &mut Context, ) -> Self { + let subscriptions = vec![ + cx.observe(&thread_store, |_, _, cx| cx.notify()), + cx.observe(&context_store, |_, _, cx| cx.notify()), + ]; + Self { thread_store, context_store, + _subscriptions: subscriptions, } } - /// Returns the number of history entries. - pub fn entry_count(&self, cx: &mut Context) -> usize { - self.entries(cx).len() - } - pub fn entries(&self, cx: &mut Context) -> Vec { let mut history_entries = Vec::new(); diff --git a/crates/agent/src/thread_history.rs b/crates/agent/src/thread_history.rs index 7170cc02eb..1fcec3a777 100644 --- a/crates/agent/src/thread_history.rs +++ b/crates/agent/src/thread_history.rs @@ -1,52 +1,176 @@ +use std::sync::Arc; + use assistant_context_editor::SavedContextMetadata; +use editor::{Editor, EditorEvent}; +use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - App, Entity, FocusHandle, Focusable, ScrollStrategy, UniformListScrollHandle, WeakEntity, - uniform_list, + App, Entity, FocusHandle, Focusable, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, + Window, uniform_list, }; use time::{OffsetDateTime, UtcOffset}; -use ui::{IconButtonShape, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use ui::{HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use util::ResultExt; use crate::history_store::{HistoryEntry, HistoryStore}; use crate::thread_store::SerializedThreadMetadata; use crate::{AssistantPanel, RemoveSelectedThread}; pub struct ThreadHistory { - focus_handle: FocusHandle, assistant_panel: WeakEntity, - history_store: Entity, scroll_handle: UniformListScrollHandle, selected_index: usize, + search_query: SharedString, + search_editor: Entity, + all_entries: Arc>, + matches: Vec, + _subscriptions: Vec, + _search_task: Option>, } impl ThreadHistory { pub(crate) fn new( assistant_panel: WeakEntity, history_store: Entity, + window: &mut Window, cx: &mut Context, ) -> Self { + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search threads...", cx); + editor + }); + + let search_editor_subscription = cx.subscribe_in( + &search_editor, + window, + |this, search_editor, event, window, cx| { + if let EditorEvent::BufferEdited = event { + let query = search_editor.read(cx).text(cx); + this.search_query = query.into(); + this.update_search(window, cx); + } + }, + ); + + let entries: Arc> = history_store + .update(cx, |store, cx| store.entries(cx)) + .into(); + + let history_store_subscription = + cx.observe_in(&history_store, window, |this, history_store, window, cx| { + this.all_entries = history_store + .update(cx, |store, cx| store.entries(cx)) + .into(); + this.matches.clear(); + this.update_search(window, cx); + }); + Self { - focus_handle: cx.focus_handle(), assistant_panel, - history_store, scroll_handle: UniformListScrollHandle::default(), selected_index: 0, + search_query: SharedString::new_static(""), + all_entries: entries, + matches: Vec::new(), + search_editor, + _subscriptions: vec![search_editor_subscription, history_store_subscription], + _search_task: None, + } + } + + fn update_search(&mut self, _window: &mut Window, cx: &mut Context) { + self._search_task.take(); + + if self.has_search_query() { + self.perform_search(cx); + } else { + self.matches.clear(); + self.set_selected_index(0, cx); + cx.notify(); + } + } + + fn perform_search(&mut self, cx: &mut Context) { + let query = self.search_query.clone(); + let all_entries = self.all_entries.clone(); + + let task = cx.spawn(async move |this, cx| { + let executor = cx.background_executor().clone(); + + let matches = cx + .background_spawn(async move { + let mut candidates = Vec::with_capacity(all_entries.len()); + + for (idx, entry) in all_entries.iter().enumerate() { + match entry { + HistoryEntry::Thread(thread) => { + candidates.push(StringMatchCandidate::new(idx, &thread.summary)); + } + HistoryEntry::Context(context) => { + candidates.push(StringMatchCandidate::new(idx, &context.title)); + } + } + } + + const MAX_MATCHES: usize = 100; + + fuzzy::match_strings( + &candidates, + &query, + false, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await + }) + .await; + + this.update(cx, |this, cx| { + this.matches = matches; + this.set_selected_index(0, cx); + cx.notify(); + }) + .log_err(); + }); + + self._search_task = Some(task); + } + + fn has_search_query(&self) -> bool { + !self.search_query.is_empty() + } + + fn matched_count(&self) -> usize { + if self.has_search_query() { + self.matches.len() + } else { + self.all_entries.len() + } + } + + fn get_match(&self, ix: usize) -> Option<&HistoryEntry> { + if self.has_search_query() { + self.matches + .get(ix) + .and_then(|m| self.all_entries.get(m.candidate_id)) + } else { + self.all_entries.get(ix) } } pub fn select_previous( &mut self, _: &menu::SelectPrevious, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { - let count = self - .history_store - .update(cx, |this, cx| this.entry_count(cx)); + let count = self.matched_count(); if count > 0 { if self.selected_index == 0 { - self.set_selected_index(count - 1, window, cx); + self.set_selected_index(count - 1, cx); } else { - self.set_selected_index(self.selected_index - 1, window, cx); + self.set_selected_index(self.selected_index - 1, cx); } } } @@ -54,40 +178,39 @@ impl ThreadHistory { pub fn select_next( &mut self, _: &menu::SelectNext, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { - let count = self - .history_store - .update(cx, |this, cx| this.entry_count(cx)); + let count = self.matched_count(); if count > 0 { if self.selected_index == count - 1 { - self.set_selected_index(0, window, cx); + self.set_selected_index(0, cx); } else { - self.set_selected_index(self.selected_index + 1, window, cx); + self.set_selected_index(self.selected_index + 1, cx); } } } - fn select_first(&mut self, _: &menu::SelectFirst, window: &mut Window, cx: &mut Context) { - let count = self - .history_store - .update(cx, |this, cx| this.entry_count(cx)); + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + let count = self.matched_count(); if count > 0 { - self.set_selected_index(0, window, cx); + self.set_selected_index(0, cx); } } - fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context) { - let count = self - .history_store - .update(cx, |this, cx| this.entry_count(cx)); + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + let count = self.matched_count(); if count > 0 { - self.set_selected_index(count - 1, window, cx); + self.set_selected_index(count - 1, cx); } } - fn set_selected_index(&mut self, index: usize, _window: &mut Window, cx: &mut Context) { + fn set_selected_index(&mut self, index: usize, cx: &mut Context) { self.selected_index = index; self.scroll_handle .scroll_to_item(index, ScrollStrategy::Top); @@ -95,23 +218,23 @@ impl ThreadHistory { } fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let entries = self.history_store.update(cx, |this, cx| this.entries(cx)); + if let Some(entry) = self.get_match(self.selected_index) { + let task_result = match entry { + HistoryEntry::Thread(thread) => self + .assistant_panel + .update(cx, move |this, cx| this.open_thread(&thread.id, window, cx)) + .ok(), + HistoryEntry::Context(context) => self + .assistant_panel + .update(cx, move |this, cx| { + this.open_saved_prompt_editor(context.path.clone(), window, cx) + }) + .ok(), + }; - if let Some(entry) = entries.get(self.selected_index) { - match entry { - HistoryEntry::Thread(thread) => { - self.assistant_panel - .update(cx, move |this, cx| this.open_thread(&thread.id, window, cx)) - .ok(); - } - HistoryEntry::Context(context) => { - self.assistant_panel - .update(cx, move |this, cx| { - this.open_saved_prompt_editor(context.path.clone(), window, cx) - }) - .ok(); - } - } + if let Some(task) = task_result { + task.detach_and_log_err(cx); + }; cx.notify(); } @@ -120,12 +243,10 @@ impl ThreadHistory { fn remove_selected_thread( &mut self, _: &RemoveSelectedThread, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { - let entries = self.history_store.update(cx, |this, cx| this.entries(cx)); - - if let Some(entry) = entries.get(self.selected_index) { + if let Some(entry) = self.get_match(self.selected_index) { match entry { HistoryEntry::Thread(thread) => { self.assistant_panel @@ -143,72 +264,117 @@ impl ThreadHistory { } } + self.update_search(window, cx); + cx.notify(); } } } impl Focusable for ThreadHistory { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_editor.focus_handle(cx) } } impl Render for ThreadHistory { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let history_entries = self.history_store.update(cx, |this, cx| this.entries(cx)); let selected_index = self.selected_index; v_flex() - .id("thread-history-container") .key_context("ThreadHistory") - .track_focus(&self.focus_handle) - .overflow_y_scroll() .size_full() - .p_1() .on_action(cx.listener(Self::select_previous)) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::remove_selected_thread)) - .map(|history| { - if history_entries.is_empty() { - history - .justify_center() + .when(!self.all_entries.is_empty(), |parent| { + parent.child( + h_flex() + .h(px(41.)) // Match the toolbar perfectly + .w_full() + .py_1() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(self.search_editor.clone()), + ) + }) + .child({ + let view = v_flex().overflow_hidden().flex_grow(); + + if self.all_entries.is_empty() { + view.justify_center() .child( h_flex().w_full().justify_center().child( Label::new("You don't have any past threads yet.") .size(LabelSize::Small), ), ) + } else if self.has_search_query() && self.matches.is_empty() { + view.justify_center().child( + h_flex().w_full().justify_center().child( + Label::new("No threads match your search.").size(LabelSize::Small), + ), + ) } else { - history.child( + view.p_1().child( uniform_list( cx.entity().clone(), "thread-history", - history_entries.len(), + self.matched_count(), move |history, range, _window, _cx| { - history_entries[range] - .iter() - .enumerate() - .map(|(index, entry)| { - h_flex().w_full().pb_1().child(match entry { - HistoryEntry::Thread(thread) => PastThread::new( - thread.clone(), - history.assistant_panel.clone(), - selected_index == index, - ) - .into_any_element(), - HistoryEntry::Context(context) => PastContext::new( - context.clone(), - history.assistant_panel.clone(), - selected_index == index, - ) - .into_any_element(), - }) + let range_start = range.start; + let assistant_panel = history.assistant_panel.clone(); + + let render_item = |index: usize, + entry: &HistoryEntry, + highlight_positions: Vec| + -> Div { + h_flex().w_full().pb_1().child(match entry { + HistoryEntry::Thread(thread) => PastThread::new( + thread.clone(), + assistant_panel.clone(), + selected_index == index + range_start, + highlight_positions, + ) + .into_any_element(), + HistoryEntry::Context(context) => PastContext::new( + context.clone(), + assistant_panel.clone(), + selected_index == index + range_start, + highlight_positions, + ) + .into_any_element(), }) - .collect() + }; + + if history.has_search_query() { + history.matches[range] + .iter() + .enumerate() + .filter_map(|(index, m)| { + history.all_entries.get(m.candidate_id).map(|entry| { + render_item(index, entry, m.positions.clone()) + }) + }) + .collect() + } else { + history.all_entries[range] + .iter() + .enumerate() + .map(|(index, entry)| render_item(index, entry, vec![])) + .collect() + } }, ) .track_scroll(self.scroll_handle.clone()) @@ -224,6 +390,7 @@ pub struct PastThread { thread: SerializedThreadMetadata, assistant_panel: WeakEntity, selected: bool, + highlight_positions: Vec, } impl PastThread { @@ -231,11 +398,13 @@ impl PastThread { thread: SerializedThreadMetadata, assistant_panel: WeakEntity, selected: bool, + highlight_positions: Vec, ) -> Self { Self { thread, assistant_panel, selected, + highlight_positions, } } } @@ -258,9 +427,11 @@ impl RenderOnce for PastThread { .toggle_state(self.selected) .spacing(ListItemSpacing::Sparse) .start_slot( - div() - .max_w_4_5() - .child(Label::new(summary).size(LabelSize::Small).truncate()), + div().max_w_4_5().child( + HighlightedLabel::new(summary, self.highlight_positions) + .size(LabelSize::Small) + .truncate(), + ), ) .end_slot( h_flex() @@ -318,6 +489,7 @@ pub struct PastContext { context: SavedContextMetadata, assistant_panel: WeakEntity, selected: bool, + highlight_positions: Vec, } impl PastContext { @@ -325,11 +497,13 @@ impl PastContext { context: SavedContextMetadata, assistant_panel: WeakEntity, selected: bool, + highlight_positions: Vec, ) -> Self { Self { context, assistant_panel, selected, + highlight_positions, } } } @@ -337,7 +511,6 @@ impl PastContext { impl RenderOnce for PastContext { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let summary = self.context.title; - let context_timestamp = time_format::format_localized_timestamp( OffsetDateTime::from_unix_timestamp(self.context.mtime.timestamp()).unwrap(), OffsetDateTime::now_utc(), @@ -354,9 +527,11 @@ impl RenderOnce for PastContext { .toggle_state(self.selected) .spacing(ListItemSpacing::Sparse) .start_slot( - div() - .max_w_4_5() - .child(Label::new(summary).size(LabelSize::Small).truncate()), + div().max_w_4_5().child( + HighlightedLabel::new(summary, self.highlight_positions) + .size(LabelSize::Small) + .truncate(), + ), ) .end_slot( h_flex()