agent: Add search to Thread History (#28085)

Agus Zubiaga , Bennet Bo Fenner , and Danilo Leal created

![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 <bennetbo@gmx.de>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

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(-)

Detailed changes

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()
                                         }
                                     }

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<ThreadStore>,
     context_store: Entity<assistant_context_editor::ContextStore>,
+    _subscriptions: Vec<gpui::Subscription>,
 }
 
 impl HistoryStore {
     pub fn new(
         thread_store: Entity<ThreadStore>,
         context_store: Entity<assistant_context_editor::ContextStore>,
-        _cx: &mut Context<Self>,
+        cx: &mut Context<Self>,
     ) -> 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<Self>) -> usize {
-        self.entries(cx).len()
-    }
-
     pub fn entries(&self, cx: &mut Context<Self>) -> Vec<HistoryEntry> {
         let mut history_entries = Vec::new();
 

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<AssistantPanel>,
-    history_store: Entity<HistoryStore>,
     scroll_handle: UniformListScrollHandle,
     selected_index: usize,
+    search_query: SharedString,
+    search_editor: Entity<Editor>,
+    all_entries: Arc<Vec<HistoryEntry>>,
+    matches: Vec<StringMatch>,
+    _subscriptions: Vec<gpui::Subscription>,
+    _search_task: Option<Task<()>>,
 }
 
 impl ThreadHistory {
     pub(crate) fn new(
         assistant_panel: WeakEntity<AssistantPanel>,
         history_store: Entity<HistoryStore>,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) -> 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<Vec<_>> = 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>) {
+        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<Self>) {
+        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<Self>,
     ) {
-        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<Self>,
     ) {
-        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<Self>) {
-        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<Self>,
+    ) {
+        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<Self>) {
-        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<Self>) {
+        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<Self>) {
+    fn set_selected_index(&mut self, index: usize, cx: &mut Context<Self>) {
         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<Self>) {
-        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<Self>,
     ) {
-        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<Self>) -> 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<usize>|
+                                 -> 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<AssistantPanel>,
     selected: bool,
+    highlight_positions: Vec<usize>,
 }
 
 impl PastThread {
@@ -231,11 +398,13 @@ impl PastThread {
         thread: SerializedThreadMetadata,
         assistant_panel: WeakEntity<AssistantPanel>,
         selected: bool,
+        highlight_positions: Vec<usize>,
     ) -> 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<AssistantPanel>,
     selected: bool,
+    highlight_positions: Vec<usize>,
 }
 
 impl PastContext {
@@ -325,11 +497,13 @@ impl PastContext {
         context: SavedContextMetadata,
         assistant_panel: WeakEntity<AssistantPanel>,
         selected: bool,
+        highlight_positions: Vec<usize>,
     ) -> 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()