Improve UX for saved contexts (#12721)

Antonio Scandurra created

Release Notes:

- Added search for saved contexts.
- Fixed a bug that caused titles generate by the LLM to be longer than
one line.

Change summary

crates/assistant/src/assistant.rs          |   4 
crates/assistant/src/assistant_panel.rs    | 279 +++++++++++++++--------
crates/assistant/src/conversation_store.rs | 203 +++++++++++++++++
crates/assistant/src/saved_conversation.rs | 126 ----------
4 files changed, 387 insertions(+), 225 deletions(-)

Detailed changes

crates/assistant/src/assistant.rs 🔗

@@ -1,11 +1,11 @@
 pub mod assistant_panel;
 pub mod assistant_settings;
 mod completion_provider;
+mod conversation_store;
 mod inline_assistant;
 mod model_selector;
 mod prompt_library;
 mod prompts;
-mod saved_conversation;
 mod search;
 mod slash_command;
 mod streaming_diff;
@@ -17,10 +17,10 @@ use assistant_slash_command::SlashCommandRegistry;
 use client::{proto, Client};
 use command_palette_hooks::CommandPaletteFilter;
 pub(crate) use completion_provider::*;
+pub(crate) use conversation_store::*;
 use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal};
 pub(crate) use inline_assistant::*;
 pub(crate) use model_selector::*;
-pub(crate) use saved_conversation::*;
 use semantic_index::{CloudEmbeddingProvider, SemanticIndex};
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};

crates/assistant/src/assistant_panel.rs 🔗

@@ -6,10 +6,10 @@ use crate::{
         default_command::DefaultSlashCommand, SlashCommandCompletionProvider, SlashCommandLine,
         SlashCommandRegistry,
     },
-    ApplyEdit, Assist, CompletionProvider, ConfirmCommand, CycleMessageRole, InlineAssist,
-    InlineAssistant, LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata,
-    MessageStatus, ModelSelector, QuoteSelection, ResetKey, Role, SavedConversation,
-    SavedConversationMetadata, SavedMessage, Split, ToggleFocus, ToggleHistory,
+    ApplyEdit, Assist, CompletionProvider, ConfirmCommand, ConversationStore, CycleMessageRole,
+    InlineAssist, InlineAssistant, LanguageModelRequest, LanguageModelRequestMessage, MessageId,
+    MessageMetadata, MessageStatus, ModelSelector, QuoteSelection, ResetKey, Role,
+    SavedConversation, SavedConversationMetadata, SavedMessage, Split, ToggleFocus, ToggleHistory,
     ToggleModelSelector,
 };
 use anyhow::{anyhow, Result};
@@ -29,17 +29,18 @@ use fs::Fs;
 use futures::future::Shared;
 use futures::{FutureExt, StreamExt};
 use gpui::{
-    div, point, rems, uniform_list, Action, AnyElement, AnyView, AppContext, AsyncAppContext,
-    AsyncWindowContext, ClipboardItem, Context, Empty, EventEmitter, FocusHandle, FocusableView,
-    InteractiveElement, IntoElement, Model, ModelContext, ParentElement, Pixels, Render,
-    SharedString, StatefulInteractiveElement, Styled, Subscription, Task, UniformListScrollHandle,
-    UpdateGlobal, View, ViewContext, VisualContext, WeakView, WindowContext,
+    div, point, rems, Action, AnyElement, AnyView, AppContext, AsyncAppContext, AsyncWindowContext,
+    ClipboardItem, Context, Empty, EventEmitter, FocusHandle, FocusableView, InteractiveElement,
+    IntoElement, Model, ModelContext, ParentElement, Pixels, Render, SharedString,
+    StatefulInteractiveElement, Styled, Subscription, Task, UpdateGlobal, View, ViewContext,
+    VisualContext, WeakView, WindowContext,
 };
 use language::{
     language_settings::SoftWrap, AnchorRangeExt, AutoindentMode, Buffer, LanguageRegistry,
     LspAdapterDelegate, OffsetRangeExt as _, Point, ToOffset as _,
 };
 use multi_buffer::MultiBufferRow;
+use picker::{Picker, PickerDelegate};
 use project::{Project, ProjectLspAdapterDelegate, ProjectTransaction};
 use search::{buffer_search::DivRegistrar, BufferSearchBar};
 use settings::Settings;
@@ -54,8 +55,8 @@ use std::{
 };
 use telemetry_events::AssistantKind;
 use ui::{
-    popover_menu, prelude::*, ButtonLike, ContextMenu, ElevationIndex, KeyBinding,
-    PopoverMenuHandle, Tab, TabBar, Tooltip,
+    popover_menu, prelude::*, ButtonLike, ContextMenu, ElevationIndex, KeyBinding, ListItem,
+    ListItemSpacing, PopoverMenuHandle, Tab, TabBar, Tooltip,
 };
 use util::{paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt};
 use uuid::Uuid;
@@ -93,8 +94,8 @@ pub struct AssistantPanel {
     height: Option<Pixels>,
     active_conversation_editor: Option<ActiveConversationEditor>,
     show_saved_conversations: bool,
-    saved_conversations: Vec<SavedConversationMetadata>,
-    saved_conversations_scroll_handle: UniformListScrollHandle,
+    conversation_store: Model<ConversationStore>,
+    saved_conversation_picker: View<Picker<SavedConversationPickerDelegate>>,
     zoomed: bool,
     focus_handle: FocusHandle,
     toolbar: View<Toolbar>,
@@ -103,11 +104,102 @@ pub struct AssistantPanel {
     fs: Arc<dyn Fs>,
     telemetry: Arc<Telemetry>,
     _subscriptions: Vec<Subscription>,
-    _watch_saved_conversations: Task<Result<()>>,
     authentication_prompt: Option<AnyView>,
     model_menu_handle: PopoverMenuHandle<ContextMenu>,
 }
 
+struct SavedConversationPickerDelegate {
+    store: Model<ConversationStore>,
+    matches: Vec<SavedConversationMetadata>,
+    selected_index: usize,
+}
+
+enum SavedConversationPickerEvent {
+    Confirmed { path: PathBuf },
+}
+
+impl EventEmitter<SavedConversationPickerEvent> for Picker<SavedConversationPickerDelegate> {}
+
+impl SavedConversationPickerDelegate {
+    fn new(store: Model<ConversationStore>) -> Self {
+        Self {
+            store,
+            matches: Vec::new(),
+            selected_index: 0,
+        }
+    }
+}
+
+impl PickerDelegate for SavedConversationPickerDelegate {
+    type ListItem = ListItem;
+
+    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 placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
+        "Search...".into()
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
+        let search = self.store.read(cx).search(query, cx);
+        cx.spawn(|this, mut cx| async move {
+            let matches = search.await;
+            this.update(&mut cx, |this, cx| {
+                this.delegate.matches = matches;
+                this.delegate.selected_index = 0;
+                cx.notify();
+            })
+            .ok();
+        })
+    }
+
+    fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
+        if let Some(metadata) = self.matches.get(self.selected_index) {
+            cx.emit(SavedConversationPickerEvent::Confirmed {
+                path: metadata.path.clone(),
+            })
+        }
+    }
+
+    fn dismissed(&mut self, _cx: &mut ViewContext<Picker<Self>>) {}
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _cx: &mut ViewContext<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let conversation = self.matches.get(ix)?;
+        Some(
+            ListItem::new(ix)
+                .inset(true)
+                .spacing(ListItemSpacing::Sparse)
+                .selected(selected)
+                .child(
+                    div()
+                        .flex()
+                        .w_full()
+                        .gap_2()
+                        .child(
+                            Label::new(conversation.mtime.format("%F %I:%M%p").to_string())
+                                .color(Color::Muted)
+                                .size(LabelSize::Small),
+                        )
+                        .child(Label::new(conversation.title.clone()).size(LabelSize::Small)),
+                ),
+        )
+    }
+}
+
 struct ActiveConversationEditor {
     editor: View<ConversationEditor>,
     _subscriptions: Vec<Subscription>,
@@ -120,35 +212,14 @@ impl AssistantPanel {
     ) -> Task<Result<View<Self>>> {
         cx.spawn(|mut cx| async move {
             let fs = workspace.update(&mut cx, |workspace, _| workspace.app_state().fs.clone())?;
-            let saved_conversations = SavedConversationMetadata::list(fs.clone())
-                .await
-                .log_err()
-                .unwrap_or_default();
+            let conversation_store = cx
+                .update(|cx| ConversationStore::new(fs.clone(), cx))?
+                .await?;
 
             // TODO: deserialize state.
             let workspace_handle = workspace.clone();
             workspace.update(&mut cx, |workspace, cx| {
                 cx.new_view::<Self>(|cx| {
-                    const CONVERSATION_WATCH_DURATION: Duration = Duration::from_millis(100);
-                    let _watch_saved_conversations = cx.spawn(move |this, mut cx| async move {
-                        let (mut events, _) = fs
-                            .watch(&CONVERSATIONS_DIR, CONVERSATION_WATCH_DURATION)
-                            .await;
-                        while events.next().await.is_some() {
-                            let saved_conversations = SavedConversationMetadata::list(fs.clone())
-                                .await
-                                .log_err()
-                                .unwrap_or_default();
-                            this.update(&mut cx, |this, cx| {
-                                this.saved_conversations = saved_conversations;
-                                cx.notify();
-                            })
-                            .ok();
-                        }
-
-                        anyhow::Ok(())
-                    });
-
                     let toolbar = cx.new_view(|cx| {
                         let mut toolbar = Toolbar::new();
                         toolbar.set_can_navigate(false, cx);
@@ -156,6 +227,15 @@ impl AssistantPanel {
                         toolbar
                     });
 
+                    let saved_conversation_picker = cx.new_view(|cx| {
+                        Picker::uniform_list(
+                            SavedConversationPickerDelegate::new(conversation_store.clone()),
+                            cx,
+                        )
+                        .modal(false)
+                        .max_height(None)
+                    });
+
                     let focus_handle = cx.focus_handle();
                     let subscriptions = vec![
                         cx.on_focus_in(&focus_handle, Self::focus_in),
@@ -169,6 +249,14 @@ impl AssistantPanel {
                                     CompletionProvider::global(cx).settings_version();
                             }
                         }),
+                        cx.observe(&conversation_store, |this, _, cx| {
+                            this.saved_conversation_picker
+                                .update(cx, |picker, cx| picker.refresh(cx));
+                        }),
+                        cx.subscribe(
+                            &saved_conversation_picker,
+                            Self::handle_saved_conversation_picker_event,
+                        ),
                     ];
 
                     cx.observe_global::<FileIcons>(|_, cx| {
@@ -180,8 +268,8 @@ impl AssistantPanel {
                         workspace: workspace_handle,
                         active_conversation_editor: None,
                         show_saved_conversations: false,
-                        saved_conversations,
-                        saved_conversations_scroll_handle: Default::default(),
+                        saved_conversation_picker,
+                        conversation_store,
                         zoomed: false,
                         focus_handle,
                         toolbar,
@@ -192,7 +280,6 @@ impl AssistantPanel {
                         width: None,
                         height: None,
                         _subscriptions: subscriptions,
-                        _watch_saved_conversations,
                         authentication_prompt: None,
                         model_menu_handle: PopoverMenuHandle::default(),
                     }
@@ -206,8 +293,10 @@ impl AssistantPanel {
             .update(cx, |toolbar, cx| toolbar.focus_changed(true, cx));
         cx.notify();
         if self.focus_handle.is_focused(cx) {
-            if let Some(editor) = self.active_conversation_editor() {
-                cx.focus_view(editor);
+            if self.show_saved_conversations {
+                cx.focus_view(&self.saved_conversation_picker);
+            } else if let Some(conversation) = self.active_conversation_editor() {
+                cx.focus_view(conversation);
             }
         }
     }
@@ -251,6 +340,20 @@ impl AssistantPanel {
         }
     }
 
+    fn handle_saved_conversation_picker_event(
+        &mut self,
+        _picker: View<Picker<SavedConversationPickerDelegate>>,
+        event: &SavedConversationPickerEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            SavedConversationPickerEvent::Confirmed { path } => {
+                self.open_conversation(path.clone(), cx)
+                    .detach_and_log_err(cx);
+            }
+        }
+    }
+
     pub fn inline_assist(
         workspace: &mut Workspace,
         _: &InlineAssist,
@@ -409,17 +512,29 @@ impl AssistantPanel {
     }
 
     fn toggle_history(&mut self, _: &ToggleHistory, cx: &mut ViewContext<Self>) {
-        self.show_saved_conversations = !self.show_saved_conversations;
-        cx.notify();
+        if self.show_saved_conversations {
+            self.hide_history(cx);
+        } else {
+            self.show_history(cx);
+        }
     }
 
     fn show_history(&mut self, cx: &mut ViewContext<Self>) {
+        cx.focus_view(&self.saved_conversation_picker);
         if !self.show_saved_conversations {
             self.show_saved_conversations = true;
             cx.notify();
         }
     }
 
+    fn hide_history(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(editor) = self.active_conversation_editor() {
+            cx.focus_view(&editor);
+            self.show_saved_conversations = false;
+            cx.notify();
+        }
+    }
+
     fn deploy(&mut self, action: &search::buffer_search::Deploy, cx: &mut ViewContext<Self>) {
         let mut propagate = true;
         if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
@@ -613,37 +728,10 @@ impl AssistantPanel {
             })
     }
 
-    fn render_saved_conversation(
-        &mut self,
-        index: usize,
-        cx: &mut ViewContext<Self>,
-    ) -> impl IntoElement {
-        let conversation = &self.saved_conversations[index];
-        let path = conversation.path.clone();
-
-        ButtonLike::new(index)
-            .on_click(cx.listener(move |this, _, cx| {
-                this.open_conversation(path.clone(), cx)
-                    .detach_and_log_err(cx)
-            }))
-            .full_width()
-            .child(
-                div()
-                    .flex()
-                    .w_full()
-                    .gap_2()
-                    .child(
-                        Label::new(conversation.mtime.format("%F %I:%M%p").to_string())
-                            .color(Color::Muted)
-                            .size(LabelSize::Small),
-                    )
-                    .child(Label::new(conversation.title.clone()).size(LabelSize::Small)),
-            )
-    }
-
     fn open_conversation(&mut self, path: PathBuf, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
         cx.focus(&self.focus_handle);
 
+        let saved_conversation = self.conversation_store.read(cx).load(path.clone(), cx);
         let fs = self.fs.clone();
         let workspace = self.workspace.clone();
         let slash_commands = self.slash_commands.clone();
@@ -658,7 +746,7 @@ impl AssistantPanel {
             .flatten();
 
         cx.spawn(|this, mut cx| async move {
-            let saved_conversation = SavedConversation::load(&path, fs.as_ref()).await?;
+            let saved_conversation = saved_conversation.await?;
             let conversation = Conversation::deserialize(
                 saved_conversation,
                 path.clone(),
@@ -705,7 +793,13 @@ impl AssistantPanel {
                     .h(rems(Tab::CONTAINER_HEIGHT_IN_REMS))
                     .flex_1()
                     .px_2()
-                    .child(Label::new(editor.read(cx).title(cx)).into_element())
+                    .child(
+                        div()
+                            .id("title")
+                            .cursor_pointer()
+                            .on_click(cx.listener(|this, _, cx| this.hide_history(cx)))
+                            .child(Label::new(editor.read(cx).title(cx))),
+                    )
             }))
             .end_child(
                 h_flex()
@@ -780,22 +874,10 @@ impl AssistantPanel {
             })
             .child(contents.flex_1().child(
                 if self.show_saved_conversations || self.active_conversation_editor().is_none() {
-                    let view = cx.view().clone();
-                    let scroll_handle = self.saved_conversations_scroll_handle.clone();
-                    let conversation_count = self.saved_conversations.len();
-                    uniform_list(
-                        view,
-                        "saved_conversations",
-                        conversation_count,
-                        |this, range, cx| {
-                            range
-                                .map(|ix| this.render_saved_conversation(ix, cx))
-                                .collect()
-                        },
-                    )
-                    .size_full()
-                    .track_scroll(scroll_handle)
-                    .into_any_element()
+                    div()
+                        .size_full()
+                        .child(self.saved_conversation_picker.clone())
+                        .into_any_element()
                 } else if let Some(editor) = self.active_conversation_editor() {
                     let editor = editor.clone();
                     div()
@@ -1809,11 +1891,10 @@ impl Conversation {
 
             let messages = self
                 .messages(cx)
-                .take(2)
                 .map(|message| message.to_request_message(self.buffer.read(cx)))
                 .chain(Some(LanguageModelRequestMessage {
                     role: Role::User,
-                    content: "Summarize the conversation into a short title without punctuation"
+                    content: "Summarize the conversation into a short title without punctuation."
                         .into(),
                 }));
             let request = LanguageModelRequest {
@@ -1830,13 +1911,17 @@ impl Conversation {
 
                     while let Some(message) = messages.next().await {
                         let text = message?;
+                        let mut lines = text.lines();
                         this.update(&mut cx, |this, cx| {
-                            this.summary
-                                .get_or_insert(Default::default())
-                                .text
-                                .push_str(&text);
+                            let summary = this.summary.get_or_insert(Default::default());
+                            summary.text.extend(lines.next());
                             cx.emit(ConversationEvent::SummaryChanged);
                         })?;
+
+                        // Stop if the LLM generated multiple lines.
+                        if lines.next().is_some() {
+                            break;
+                        }
                     }
 
                     this.update(&mut cx, |this, cx| {

crates/assistant/src/conversation_store.rs 🔗

@@ -0,0 +1,203 @@
+use crate::{assistant_settings::OpenAiModel, MessageId, MessageMetadata};
+use anyhow::{anyhow, Result};
+use collections::HashMap;
+use fs::Fs;
+use futures::StreamExt;
+use fuzzy::StringMatchCandidate;
+use gpui::{AppContext, Model, ModelContext, Task};
+use regex::Regex;
+use serde::{Deserialize, Serialize};
+use std::{cmp::Reverse, ffi::OsStr, path::PathBuf, sync::Arc, time::Duration};
+use ui::Context;
+use util::{paths::CONVERSATIONS_DIR, ResultExt, TryFutureExt};
+
+#[derive(Serialize, Deserialize)]
+pub struct SavedMessage {
+    pub id: MessageId,
+    pub start: usize,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct SavedConversation {
+    pub id: Option<String>,
+    pub zed: String,
+    pub version: String,
+    pub text: String,
+    pub messages: Vec<SavedMessage>,
+    pub message_metadata: HashMap<MessageId, MessageMetadata>,
+    pub summary: String,
+}
+
+impl SavedConversation {
+    pub const VERSION: &'static str = "0.2.0";
+}
+
+#[derive(Serialize, Deserialize)]
+struct SavedConversationV0_1_0 {
+    id: Option<String>,
+    zed: String,
+    version: String,
+    text: String,
+    messages: Vec<SavedMessage>,
+    message_metadata: HashMap<MessageId, MessageMetadata>,
+    summary: String,
+    api_url: Option<String>,
+    model: OpenAiModel,
+}
+
+#[derive(Clone)]
+pub struct SavedConversationMetadata {
+    pub title: String,
+    pub path: PathBuf,
+    pub mtime: chrono::DateTime<chrono::Local>,
+}
+
+pub struct ConversationStore {
+    conversations_metadata: Vec<SavedConversationMetadata>,
+    fs: Arc<dyn Fs>,
+    _watch_updates: Task<Option<()>>,
+}
+
+impl ConversationStore {
+    pub fn new(fs: Arc<dyn Fs>, cx: &mut AppContext) -> Task<Result<Model<Self>>> {
+        cx.spawn(|mut cx| async move {
+            const CONVERSATION_WATCH_DURATION: Duration = Duration::from_millis(100);
+            let (mut events, _) = fs
+                .watch(&CONVERSATIONS_DIR, CONVERSATION_WATCH_DURATION)
+                .await;
+
+            let this = cx.new_model(|cx: &mut ModelContext<Self>| Self {
+                conversations_metadata: Vec::new(),
+                fs,
+                _watch_updates: cx.spawn(|this, mut cx| {
+                    async move {
+                        while events.next().await.is_some() {
+                            this.update(&mut cx, |this, cx| this.reload(cx))?
+                                .await
+                                .log_err();
+                        }
+                        anyhow::Ok(())
+                    }
+                    .log_err()
+                }),
+            })?;
+            this.update(&mut cx, |this, cx| this.reload(cx))?
+                .await
+                .log_err();
+            Ok(this)
+        })
+    }
+
+    pub fn load(&self, path: PathBuf, cx: &AppContext) -> Task<Result<SavedConversation>> {
+        let fs = self.fs.clone();
+        cx.background_executor().spawn(async move {
+            let saved_conversation = fs.load(&path).await?;
+            let saved_conversation_json =
+                serde_json::from_str::<serde_json::Value>(&saved_conversation)?;
+            match saved_conversation_json
+                .get("version")
+                .ok_or_else(|| anyhow!("version not found"))?
+            {
+                serde_json::Value::String(version) => match version.as_str() {
+                    SavedConversation::VERSION => Ok(serde_json::from_value::<SavedConversation>(
+                        saved_conversation_json,
+                    )?),
+                    "0.1.0" => {
+                        let saved_conversation = serde_json::from_value::<SavedConversationV0_1_0>(
+                            saved_conversation_json,
+                        )?;
+                        Ok(SavedConversation {
+                            id: saved_conversation.id,
+                            zed: saved_conversation.zed,
+                            version: saved_conversation.version,
+                            text: saved_conversation.text,
+                            messages: saved_conversation.messages,
+                            message_metadata: saved_conversation.message_metadata,
+                            summary: saved_conversation.summary,
+                        })
+                    }
+                    _ => Err(anyhow!(
+                        "unrecognized saved conversation version: {}",
+                        version
+                    )),
+                },
+                _ => Err(anyhow!("version not found on saved conversation")),
+            }
+        })
+    }
+
+    pub fn search(&self, query: String, cx: &AppContext) -> Task<Vec<SavedConversationMetadata>> {
+        let metadata = self.conversations_metadata.clone();
+        let executor = cx.background_executor().clone();
+        cx.background_executor().spawn(async move {
+            if query.is_empty() {
+                metadata
+            } else {
+                let candidates = metadata
+                    .iter()
+                    .enumerate()
+                    .map(|(id, metadata)| StringMatchCandidate::new(id, metadata.title.clone()))
+                    .collect::<Vec<_>>();
+                let matches = fuzzy::match_strings(
+                    &candidates,
+                    &query,
+                    false,
+                    100,
+                    &Default::default(),
+                    executor,
+                )
+                .await;
+
+                matches
+                    .into_iter()
+                    .map(|mat| metadata[mat.candidate_id].clone())
+                    .collect()
+            }
+        })
+    }
+
+    fn reload(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        let fs = self.fs.clone();
+        cx.spawn(|this, mut cx| async move {
+            fs.create_dir(&CONVERSATIONS_DIR).await?;
+
+            let mut paths = fs.read_dir(&CONVERSATIONS_DIR).await?;
+            let mut conversations = Vec::<SavedConversationMetadata>::new();
+            while let Some(path) = paths.next().await {
+                let path = path?;
+                if path.extension() != Some(OsStr::new("json")) {
+                    continue;
+                }
+
+                let pattern = r" - \d+.zed.json$";
+                let re = Regex::new(pattern).unwrap();
+
+                let metadata = fs.metadata(&path).await?;
+                if let Some((file_name, metadata)) = path
+                    .file_name()
+                    .and_then(|name| name.to_str())
+                    .zip(metadata)
+                {
+                    // This is used to filter out conversations saved by the new assistant.
+                    if !re.is_match(file_name) {
+                        continue;
+                    }
+
+                    if let Some(title) = re.replace(file_name, "").lines().next() {
+                        conversations.push(SavedConversationMetadata {
+                            title: title.to_string(),
+                            path,
+                            mtime: metadata.mtime.into(),
+                        });
+                    }
+                }
+            }
+            conversations.sort_unstable_by_key(|conversation| Reverse(conversation.mtime));
+
+            this.update(&mut cx, |this, cx| {
+                this.conversations_metadata = conversations;
+                cx.notify();
+            })
+        })
+    }
+}

crates/assistant/src/saved_conversation.rs 🔗

@@ -1,126 +0,0 @@
-use crate::{assistant_settings::OpenAiModel, MessageId, MessageMetadata};
-use anyhow::{anyhow, Result};
-use collections::HashMap;
-use fs::Fs;
-use futures::StreamExt;
-use regex::Regex;
-use serde::{Deserialize, Serialize};
-use std::{
-    cmp::Reverse,
-    ffi::OsStr,
-    path::{Path, PathBuf},
-    sync::Arc,
-};
-use util::paths::CONVERSATIONS_DIR;
-
-#[derive(Serialize, Deserialize)]
-pub struct SavedMessage {
-    pub id: MessageId,
-    pub start: usize,
-}
-
-#[derive(Serialize, Deserialize)]
-pub struct SavedConversation {
-    pub id: Option<String>,
-    pub zed: String,
-    pub version: String,
-    pub text: String,
-    pub messages: Vec<SavedMessage>,
-    pub message_metadata: HashMap<MessageId, MessageMetadata>,
-    pub summary: String,
-}
-
-impl SavedConversation {
-    pub const VERSION: &'static str = "0.2.0";
-
-    pub async fn load(path: &Path, fs: &dyn Fs) -> Result<Self> {
-        let saved_conversation = fs.load(path).await?;
-        let saved_conversation_json =
-            serde_json::from_str::<serde_json::Value>(&saved_conversation)?;
-        match saved_conversation_json
-            .get("version")
-            .ok_or_else(|| anyhow!("version not found"))?
-        {
-            serde_json::Value::String(version) => match version.as_str() {
-                Self::VERSION => Ok(serde_json::from_value::<Self>(saved_conversation_json)?),
-                "0.1.0" => {
-                    let saved_conversation =
-                        serde_json::from_value::<SavedConversationV0_1_0>(saved_conversation_json)?;
-                    Ok(Self {
-                        id: saved_conversation.id,
-                        zed: saved_conversation.zed,
-                        version: saved_conversation.version,
-                        text: saved_conversation.text,
-                        messages: saved_conversation.messages,
-                        message_metadata: saved_conversation.message_metadata,
-                        summary: saved_conversation.summary,
-                    })
-                }
-                _ => Err(anyhow!(
-                    "unrecognized saved conversation version: {}",
-                    version
-                )),
-            },
-            _ => Err(anyhow!("version not found on saved conversation")),
-        }
-    }
-}
-
-#[derive(Serialize, Deserialize)]
-struct SavedConversationV0_1_0 {
-    id: Option<String>,
-    zed: String,
-    version: String,
-    text: String,
-    messages: Vec<SavedMessage>,
-    message_metadata: HashMap<MessageId, MessageMetadata>,
-    summary: String,
-    api_url: Option<String>,
-    model: OpenAiModel,
-}
-
-pub struct SavedConversationMetadata {
-    pub title: String,
-    pub path: PathBuf,
-    pub mtime: chrono::DateTime<chrono::Local>,
-}
-
-impl SavedConversationMetadata {
-    pub async fn list(fs: Arc<dyn Fs>) -> Result<Vec<Self>> {
-        fs.create_dir(&CONVERSATIONS_DIR).await?;
-
-        let mut paths = fs.read_dir(&CONVERSATIONS_DIR).await?;
-        let mut conversations = Vec::<SavedConversationMetadata>::new();
-        while let Some(path) = paths.next().await {
-            let path = path?;
-            if path.extension() != Some(OsStr::new("json")) {
-                continue;
-            }
-
-            let pattern = r" - \d+.zed.json$";
-            let re = Regex::new(pattern).unwrap();
-
-            let metadata = fs.metadata(&path).await?;
-            if let Some((file_name, metadata)) = path
-                .file_name()
-                .and_then(|name| name.to_str())
-                .zip(metadata)
-            {
-                // This is used to filter out conversations saved by the new assistant.
-                if !re.is_match(file_name) {
-                    continue;
-                }
-
-                let title = re.replace(file_name, "");
-                conversations.push(Self {
-                    title: title.into_owned(),
-                    path,
-                    mtime: metadata.mtime.into(),
-                });
-            }
-        }
-        conversations.sort_unstable_by_key(|conversation| Reverse(conversation.mtime));
-
-        Ok(conversations)
-    }
-}