assistant2: List saved conversations from disk (#11627)

Marshall Bowers created

This PR updates the saved conversation picker to use a list of
conversations retrieved from disk instead of the static placeholder
values.

Release Notes:

- N/A

Change summary

Cargo.lock                                         |  2 
crates/assistant2/Cargo.toml                       |  2 
crates/assistant2/src/saved_conversation.rs        | 75 +++++++++++----
crates/assistant2/src/saved_conversation_picker.rs | 34 +++++-
4 files changed, 84 insertions(+), 29 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -377,6 +377,7 @@ dependencies = [
  "anyhow",
  "assets",
  "assistant_tooling",
+ "chrono",
  "client",
  "collections",
  "editor",
@@ -395,6 +396,7 @@ dependencies = [
  "picker",
  "project",
  "rand 0.8.5",
+ "regex",
  "release_channel",
  "rich_text",
  "schemars",

crates/assistant2/Cargo.toml 🔗

@@ -19,6 +19,7 @@ stories = ["dep:story"]
 anyhow.workspace = true
 assistant_tooling.workspace = true
 client.workspace = true
+chrono.workspace = true
 collections.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
@@ -32,6 +33,7 @@ nanoid.workspace = true
 open_ai.workspace = true
 picker.workspace = true
 project.workspace = true
+regex.workspace = true
 rich_text.workspace = true
 schemars.workspace = true
 semantic_index.workspace = true

crates/assistant2/src/saved_conversation.rs 🔗

@@ -1,6 +1,16 @@
+use std::cmp::Reverse;
+use std::ffi::OsStr;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use anyhow::Result;
 use assistant_tooling::{SavedToolFunctionCall, SavedUserAttachment};
+use fs::Fs;
+use futures::StreamExt;
 use gpui::SharedString;
+use regex::Regex;
 use serde::{Deserialize, Serialize};
+use util::paths::CONVERSATIONS_DIR;
 
 use crate::MessageId;
 
@@ -33,25 +43,48 @@ pub struct SavedAssistantMessagePart {
     pub tool_calls: Vec<SavedToolFunctionCall>,
 }
 
-/// 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 {
-            version: "0.3.0".to_string(),
-            title: "How to get a list of exported functions in an Erlang module".to_string(),
-            messages: vec![],
-        },
-        SavedConversation {
-            version: "0.3.0".to_string(),
-            title: "7 wonders of the ancient world".to_string(),
-            messages: vec![],
-        },
-        SavedConversation {
-            version: "0.3.0".to_string(),
-            title: "Size difference between u8 and a reference to u8 in Rust".to_string(),
-            messages: vec![],
-        },
-    ]
+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::new();
+        while let Some(path) = paths.next().await {
+            let path = path?;
+            if path.extension() != Some(OsStr::new("json")) {
+                continue;
+            }
+
+            let pattern = r" - \d+.zed.\d.\d.\d.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 old 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)
+    }
 }

crates/assistant2/src/saved_conversation_picker.rs 🔗

@@ -7,7 +7,7 @@ use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
 use util::ResultExt;
 use workspace::{ModalView, Workspace};
 
-use crate::saved_conversation::{self, SavedConversation};
+use crate::saved_conversation::SavedConversationMetadata;
 use crate::ToggleSavedConversations;
 
 pub struct SavedConversationPicker {
@@ -27,10 +27,26 @@ impl FocusableView for SavedConversationPicker {
 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)
-            });
+            let fs = workspace.project().read(cx).fs().clone();
+
+            cx.spawn(|workspace, mut cx| async move {
+                let saved_conversations = SavedConversationMetadata::list(fs).await?;
+
+                cx.update(|cx| {
+                    workspace.update(cx, |workspace, cx| {
+                        workspace.toggle_modal(cx, move |cx| {
+                            let delegate = SavedConversationPickerDelegate::new(
+                                cx.view().downgrade(),
+                                saved_conversations,
+                            );
+                            Self::new(delegate, cx)
+                        });
+                    })
+                })??;
+
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
         });
     }
 
@@ -48,14 +64,16 @@ impl Render for SavedConversationPicker {
 
 pub struct SavedConversationPickerDelegate {
     view: WeakView<SavedConversationPicker>,
-    saved_conversations: Vec<SavedConversation>,
+    saved_conversations: Vec<SavedConversationMetadata>,
     selected_index: usize,
     matches: Vec<StringMatch>,
 }
 
 impl SavedConversationPickerDelegate {
-    pub fn new(weak_view: WeakView<SavedConversationPicker>) -> Self {
-        let saved_conversations = saved_conversation::placeholder_conversations();
+    pub fn new(
+        weak_view: WeakView<SavedConversationPicker>,
+        saved_conversations: Vec<SavedConversationMetadata>,
+    ) -> Self {
         let matches = saved_conversations
             .iter()
             .map(|conversation| StringMatch {