diff --git a/Cargo.lock b/Cargo.lock index a4ee355c4604..a40047b4ffd7 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index e006db6749b8..b8982b4d5ef2 100644 --- a/crates/assistant2/Cargo.toml +++ b/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 diff --git a/crates/assistant2/src/assistant2.rs b/crates/assistant2/src/assistant2.rs index 3d3d7c63165a..5c2863f58c70 100644 --- a/crates/assistant2/src/assistant2.rs +++ b/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, cx: &mut AppContext) { @@ -97,6 +108,8 @@ pub fn init(client: Arc, 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(), diff --git a/crates/assistant2/src/saved_conversation.rs b/crates/assistant2/src/saved_conversation.rs new file mode 100644 index 000000000000..ed0e6a3d4bec --- /dev/null +++ b/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, +} + +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 { + 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![], + }, + ] +} diff --git a/crates/assistant2/src/saved_conversation_picker.rs b/crates/assistant2/src/saved_conversation_picker.rs new file mode 100644 index 000000000000..3c16fdac694b --- /dev/null +++ b/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>, +} + +impl EventEmitter 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.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 { + let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx)); + Self { picker } + } +} + +impl Render for SavedConversationPicker { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + v_flex().w(rems(34.)).child(self.picker.clone()) + } +} + +pub struct SavedConversationPickerDelegate { + view: WeakView, + saved_conversations: Vec, + selected_index: usize, + matches: Vec, +} + +impl SavedConversationPickerDelegate { + pub fn new(weak_view: WeakView) -> 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 { + "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>) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + query: String, + cx: &mut ViewContext>, + ) -> 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::>(); + + 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>) { + if self.matches.is_empty() { + self.dismissed(cx); + return; + } + + // TODO: Implement selecting a saved conversation. + } + + fn dismissed(&mut self, cx: &mut ui::prelude::ViewContext>) { + self.view + .update(cx, |_, cx| cx.emit(DismissEvent)) + .log_err(); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _cx: &mut ViewContext>, + ) -> Option { + 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(), + )), + ) + } +}