assistant2: Add general structure for conversation history (#11516)

Marshall Bowers created

This PR adds the general structure for conversation history to the new
assistant.

Right now we have a placeholder button in the assistant panel that will
toggle a picker containing some placeholder saved conversations.

Release Notes:

- N/A

Change summary

Cargo.lock                                         |   2 
crates/assistant2/Cargo.toml                       |   2 
crates/assistant2/src/assistant2.rs                |  19 +
crates/assistant2/src/saved_conversation.rs        |  29 ++
crates/assistant2/src/saved_conversation_picker.rs | 188 ++++++++++++++++
5 files changed, 239 insertions(+), 1 deletion(-)

Detailed changes

Cargo.lock 🔗

@@ -383,6 +383,7 @@ dependencies = [
  "env_logger",
  "feature_flags",
  "futures 0.3.28",
+ "fuzzy",
  "gpui",
  "language",
  "languages",
@@ -390,6 +391,7 @@ dependencies = [
  "nanoid",
  "node_runtime",
  "open_ai",
+ "picker",
  "project",
  "rand 0.8.5",
  "release_channel",

crates/assistant2/Cargo.toml 🔗

@@ -23,11 +23,13 @@ collections.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
 futures.workspace = true
+fuzzy.workspace = true
 gpui.workspace = true
 language.workspace = true
 log.workspace = true
 nanoid.workspace = true
 open_ai.workspace = true
+picker.workspace = true
 project.workspace = true
 rich_text.workspace = true
 schemars.workspace = true

crates/assistant2/src/assistant2.rs 🔗

@@ -1,9 +1,12 @@
 mod assistant_settings;
 mod attachments;
 mod completion_provider;
+mod saved_conversation;
+mod saved_conversation_picker;
 mod tools;
 pub mod ui;
 
+use crate::saved_conversation_picker::SavedConversationPicker;
 use crate::{
     attachments::ActiveEditorAttachmentTool,
     tools::{CreateBufferTool, ProjectIndexTool},
@@ -57,7 +60,15 @@ pub enum SubmitMode {
     Codebase,
 }
 
-gpui::actions!(assistant2, [Cancel, ToggleFocus, DebugProjectIndex]);
+gpui::actions!(
+    assistant2,
+    [
+        Cancel,
+        ToggleFocus,
+        DebugProjectIndex,
+        ToggleSavedConversations
+    ]
+);
 gpui::impl_actions!(assistant2, [Submit]);
 
 pub fn init(client: Arc<Client>, cx: &mut AppContext) {
@@ -97,6 +108,8 @@ pub fn init(client: Arc<Client>, cx: &mut AppContext) {
         },
     )
     .detach();
+    cx.observe_new_views(SavedConversationPicker::register)
+        .detach();
 }
 
 pub fn enabled(cx: &AppContext) -> bool {
@@ -891,6 +904,10 @@ impl Render for AssistantChat {
             .on_action(cx.listener(Self::submit))
             .on_action(cx.listener(Self::cancel))
             .text_color(Color::Default.color(cx))
+            .child(
+                Button::new("open-saved-conversations", "Saved Conversations")
+                    .on_click(|_event, cx| cx.dispatch_action(Box::new(ToggleSavedConversations))),
+            )
             .child(list(self.list_state.clone()).flex_1())
             .child(Composer::new(
                 self.composer_editor.clone(),

crates/assistant2/src/saved_conversation.rs 🔗

@@ -0,0 +1,29 @@
+pub struct SavedConversation {
+    /// The title of the conversation, generated by the Assistant.
+    pub title: String,
+    pub messages: Vec<SavedMessage>,
+}
+
+pub struct SavedMessage {
+    pub text: String,
+}
+
+/// Returns a list of placeholder conversations for mocking the UI.
+///
+/// Once we have real saved conversations to pull from we can use those instead.
+pub fn placeholder_conversations() -> Vec<SavedConversation> {
+    vec![
+        SavedConversation {
+            title: "How to get a list of exported functions in an Erlang module".to_string(),
+            messages: vec![],
+        },
+        SavedConversation {
+            title: "7 wonders of the ancient world".to_string(),
+            messages: vec![],
+        },
+        SavedConversation {
+            title: "Size difference between u8 and a reference to u8 in Rust".to_string(),
+            messages: vec![],
+        },
+    ]
+}

crates/assistant2/src/saved_conversation_picker.rs 🔗

@@ -0,0 +1,188 @@
+use std::sync::Arc;
+
+use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
+use gpui::{AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, View, WeakView};
+use picker::{Picker, PickerDelegate};
+use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
+use util::ResultExt;
+use workspace::{ModalView, Workspace};
+
+use crate::saved_conversation::{self, SavedConversation};
+use crate::ToggleSavedConversations;
+
+pub struct SavedConversationPicker {
+    picker: View<Picker<SavedConversationPickerDelegate>>,
+}
+
+impl EventEmitter<DismissEvent> for SavedConversationPicker {}
+
+impl ModalView for SavedConversationPicker {}
+
+impl FocusableView for SavedConversationPicker {
+    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
+        self.picker.focus_handle(cx)
+    }
+}
+
+impl SavedConversationPicker {
+    pub fn register(workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>) {
+        workspace.register_action(|workspace, _: &ToggleSavedConversations, cx| {
+            workspace.toggle_modal(cx, move |cx| {
+                let delegate = SavedConversationPickerDelegate::new(cx.view().downgrade());
+                Self::new(delegate, cx)
+            });
+        });
+    }
+
+    pub fn new(delegate: SavedConversationPickerDelegate, cx: &mut ViewContext<Self>) -> Self {
+        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
+        Self { picker }
+    }
+}
+
+impl Render for SavedConversationPicker {
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
+        v_flex().w(rems(34.)).child(self.picker.clone())
+    }
+}
+
+pub struct SavedConversationPickerDelegate {
+    view: WeakView<SavedConversationPicker>,
+    saved_conversations: Vec<SavedConversation>,
+    selected_index: usize,
+    matches: Vec<StringMatch>,
+}
+
+impl SavedConversationPickerDelegate {
+    pub fn new(weak_view: WeakView<SavedConversationPicker>) -> Self {
+        let saved_conversations = saved_conversation::placeholder_conversations();
+        let matches = saved_conversations
+            .iter()
+            .map(|conversation| StringMatch {
+                candidate_id: 0,
+                score: 0.0,
+                positions: Default::default(),
+                string: conversation.title.clone(),
+            })
+            .collect();
+
+        Self {
+            view: weak_view,
+            saved_conversations,
+            selected_index: 0,
+            matches,
+        }
+    }
+}
+
+impl PickerDelegate for SavedConversationPickerDelegate {
+    type ListItem = ui::ListItem;
+
+    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
+        "Select saved conversation...".into()
+    }
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Picker<Self>>) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        cx: &mut ViewContext<Picker<Self>>,
+    ) -> gpui::Task<()> {
+        let background_executor = cx.background_executor().clone();
+        let candidates = self
+            .saved_conversations
+            .iter()
+            .enumerate()
+            .map(|(id, conversation)| {
+                let text = conversation.title.clone();
+
+                StringMatchCandidate {
+                    id,
+                    char_bag: text.as_str().into(),
+                    string: text,
+                }
+            })
+            .collect::<Vec<_>>();
+
+        cx.spawn(move |this, mut cx| async move {
+            let matches = if query.is_empty() {
+                candidates
+                    .into_iter()
+                    .enumerate()
+                    .map(|(index, candidate)| StringMatch {
+                        candidate_id: index,
+                        string: candidate.string,
+                        positions: Vec::new(),
+                        score: 0.0,
+                    })
+                    .collect()
+            } else {
+                match_strings(
+                    &candidates,
+                    &query,
+                    false,
+                    100,
+                    &Default::default(),
+                    background_executor,
+                )
+                .await
+            };
+
+            this.update(&mut cx, |this, _cx| {
+                this.delegate.matches = matches;
+                this.delegate.selected_index = this
+                    .delegate
+                    .selected_index
+                    .min(this.delegate.matches.len().saturating_sub(1));
+            })
+            .log_err();
+        })
+    }
+
+    fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
+        if self.matches.is_empty() {
+            self.dismissed(cx);
+            return;
+        }
+
+        // TODO: Implement selecting a saved conversation.
+    }
+
+    fn dismissed(&mut self, cx: &mut ui::prelude::ViewContext<Picker<Self>>) {
+        self.view
+            .update(cx, |_, cx| cx.emit(DismissEvent))
+            .log_err();
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _cx: &mut ViewContext<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let conversation_match = &self.matches[ix];
+        let _conversation = &self.saved_conversations[conversation_match.candidate_id];
+
+        Some(
+            ListItem::new(ix)
+                .inset(true)
+                .spacing(ListItemSpacing::Sparse)
+                .selected(selected)
+                .child(HighlightedLabel::new(
+                    conversation_match.string.clone(),
+                    conversation_match.positions.clone(),
+                )),
+        )
+    }
+}