Pin message composer to the bottom of the new assistant panel (#11186)

Max Brunsfeld , Marshall , Nate , Kyle , and Marshall Bowers created

Release Notes:

- N/A

---------

Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Nate <nate@zed.dev>
Co-authored-by: Kyle <kylek@zed.dev>
Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>

Change summary

crates/assistant2/Cargo.toml                      |   5 
crates/assistant2/examples/assistant_example.rs   | 127 ---------
crates/assistant2/examples/chat_with_functions.rs | 141 ++++++++++
crates/assistant2/examples/file_interactions.rs   | 221 ----------------
crates/assistant2/src/assistant2.rs               | 188 +++++---------
crates/assistant2/src/ui.rs                       |   3 
crates/assistant2/src/ui/composer.rs              | 222 +++++++++++++++++
7 files changed, 430 insertions(+), 477 deletions(-)

Detailed changes

crates/assistant2/Cargo.toml 🔗

@@ -8,11 +8,6 @@ license = "GPL-3.0-or-later"
 [lib]
 path = "src/assistant2.rs"
 
-[[example]]
-name = "assistant_example"
-path = "examples/assistant_example.rs"
-crate-type = ["bin"]
-
 [dependencies]
 anyhow.workspace = true
 assistant_tooling.workspace = true

crates/assistant2/examples/assistant_example.rs 🔗

@@ -1,127 +0,0 @@
-use anyhow::Context as _;
-use assets::Assets;
-use assistant2::{tools::ProjectIndexTool, AssistantPanel};
-use assistant_tooling::ToolRegistry;
-use client::Client;
-use gpui::{actions, App, AppContext, KeyBinding, Task, View, WindowOptions};
-use language::LanguageRegistry;
-use project::Project;
-use semantic_index::{OpenAiEmbeddingModel, OpenAiEmbeddingProvider, SemanticIndex};
-use settings::{KeymapFile, DEFAULT_KEYMAP_PATH};
-use std::{
-    path::{Path, PathBuf},
-    sync::Arc,
-};
-use theme::LoadThemes;
-use ui::{div, prelude::*, Render};
-use util::{http::HttpClientWithUrl, ResultExt as _};
-
-actions!(example, [Quit]);
-
-fn main() {
-    let args: Vec<String> = std::env::args().collect();
-
-    env_logger::init();
-    App::new().with_assets(Assets).run(|cx| {
-        cx.bind_keys(Some(KeyBinding::new("cmd-q", Quit, None)));
-        cx.on_action(|_: &Quit, cx: &mut AppContext| {
-            cx.quit();
-        });
-
-        if args.len() < 2 {
-            eprintln!(
-                "Usage: cargo run --example assistant_example -p assistant2 -- <project_path>"
-            );
-            cx.quit();
-            return;
-        }
-
-        settings::init(cx);
-        language::init(cx);
-        Project::init_settings(cx);
-        editor::init(cx);
-        theme::init(LoadThemes::JustBase, cx);
-        Assets.load_fonts(cx).unwrap();
-        KeymapFile::load_asset(DEFAULT_KEYMAP_PATH, cx).unwrap();
-        client::init_settings(cx);
-        release_channel::init("0.130.0", cx);
-
-        let client = Client::production(cx);
-        {
-            let client = client.clone();
-            cx.spawn(|cx| async move { client.authenticate_and_connect(false, &cx).await })
-                .detach_and_log_err(cx);
-        }
-        assistant2::init(client.clone(), cx);
-
-        let language_registry = Arc::new(LanguageRegistry::new(
-            Task::ready(()),
-            cx.background_executor().clone(),
-        ));
-        let node_runtime = node_runtime::RealNodeRuntime::new(client.http_client());
-        languages::init(language_registry.clone(), node_runtime, cx);
-
-        let http = Arc::new(HttpClientWithUrl::new("http://localhost:11434"));
-
-        let api_key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY not set");
-        let embedding_provider = OpenAiEmbeddingProvider::new(
-            http.clone(),
-            OpenAiEmbeddingModel::TextEmbedding3Small,
-            open_ai::OPEN_AI_API_URL.to_string(),
-            api_key,
-        );
-
-        cx.spawn(|mut cx| async move {
-            let mut semantic_index = SemanticIndex::new(
-                PathBuf::from("/tmp/semantic-index-db.mdb"),
-                Arc::new(embedding_provider),
-                &mut cx,
-            )
-            .await?;
-
-            let project_path = Path::new(&args[1]);
-            let project = Project::example([project_path], &mut cx).await;
-
-            cx.update(|cx| {
-                let fs = project.read(cx).fs().clone();
-
-                let project_index = semantic_index.project_index(project.clone(), cx);
-
-                cx.open_window(WindowOptions::default(), |cx| {
-                    let mut tool_registry = ToolRegistry::new();
-                    tool_registry
-                        .register(ProjectIndexTool::new(project_index.clone(), fs.clone()), cx)
-                        .context("failed to register ProjectIndexTool")
-                        .log_err();
-
-                    cx.new_view(|cx| Example::new(language_registry, Arc::new(tool_registry), cx))
-                });
-                cx.activate(true);
-            })
-        })
-        .detach_and_log_err(cx);
-    })
-}
-
-struct Example {
-    assistant_panel: View<AssistantPanel>,
-}
-
-impl Example {
-    fn new(
-        language_registry: Arc<LanguageRegistry>,
-        tool_registry: Arc<ToolRegistry>,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        Self {
-            assistant_panel: cx
-                .new_view(|cx| AssistantPanel::new(language_registry, tool_registry, cx)),
-        }
-    }
-}
-
-impl Render for Example {
-    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl ui::prelude::IntoElement {
-        div().size_full().child(self.assistant_panel.clone())
-    }
-}

crates/assistant2/examples/chat_with_functions.rs 🔗

@@ -4,15 +4,17 @@ use anyhow::{Context as _, Result};
 use assets::Assets;
 use assistant2::AssistantPanel;
 use assistant_tooling::{LanguageModelTool, ToolRegistry};
-use client::Client;
-use gpui::{actions, AnyElement, App, AppContext, KeyBinding, Task, View, WindowOptions};
+use client::{Client, UserStore};
+use fs::Fs;
+use futures::StreamExt as _;
+use gpui::{actions, AnyElement, App, AppContext, KeyBinding, Model, Task, View, WindowOptions};
 use language::LanguageRegistry;
 use project::Project;
 use rand::Rng;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{KeymapFile, DEFAULT_KEYMAP_PATH};
-use std::sync::Arc;
+use std::{path::PathBuf, sync::Arc};
 use theme::LoadThemes;
 use ui::{div, prelude::*, Render};
 use util::ResultExt as _;
@@ -159,6 +161,121 @@ impl LanguageModelTool for RollDiceTool {
     }
 }
 
+struct FileBrowserTool {
+    fs: Arc<dyn Fs>,
+    root_dir: PathBuf,
+}
+
+impl FileBrowserTool {
+    fn new(fs: Arc<dyn Fs>, root_dir: PathBuf) -> Self {
+        Self { fs, root_dir }
+    }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+struct FileBrowserParams {
+    command: FileBrowserCommand,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+enum FileBrowserCommand {
+    Ls { path: PathBuf },
+    Cat { path: PathBuf },
+}
+
+#[derive(Serialize, Deserialize)]
+enum FileBrowserOutput {
+    Ls { entries: Vec<String> },
+    Cat { content: String },
+}
+
+pub struct FileBrowserView {
+    result: Result<FileBrowserOutput>,
+}
+
+impl Render for FileBrowserView {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let Ok(output) = self.result.as_ref() else {
+            return h_flex().child("Failed to perform operation");
+        };
+
+        match output {
+            FileBrowserOutput::Ls { entries } => v_flex().children(
+                entries
+                    .into_iter()
+                    .map(|entry| h_flex().text_ui(cx).child(entry.clone())),
+            ),
+            FileBrowserOutput::Cat { content } => h_flex().child(content.clone()),
+        }
+    }
+}
+
+impl LanguageModelTool for FileBrowserTool {
+    type Input = FileBrowserParams;
+    type Output = FileBrowserOutput;
+    type View = FileBrowserView;
+
+    fn name(&self) -> String {
+        "file_browser".to_string()
+    }
+
+    fn description(&self) -> String {
+        "A tool for browsing the filesystem.".to_string()
+    }
+
+    fn execute(&self, input: &Self::Input, cx: &AppContext) -> Task<gpui::Result<Self::Output>> {
+        cx.spawn({
+            let fs = self.fs.clone();
+            let root_dir = self.root_dir.clone();
+            let input = input.clone();
+            |_cx| async move {
+                match input.command {
+                    FileBrowserCommand::Ls { path } => {
+                        let path = root_dir.join(path);
+
+                        let mut output = fs.read_dir(&path).await?;
+
+                        let mut entries = Vec::new();
+                        while let Some(entry) = output.next().await {
+                            let entry = entry?;
+                            entries.push(entry.display().to_string());
+                        }
+
+                        Ok(FileBrowserOutput::Ls { entries })
+                    }
+                    FileBrowserCommand::Cat { path } => {
+                        let path = root_dir.join(path);
+
+                        let output = fs.load(&path).await?;
+
+                        Ok(FileBrowserOutput::Cat { content: output })
+                    }
+                }
+            }
+        })
+    }
+
+    fn output_view(
+        _tool_call_id: String,
+        _input: Self::Input,
+        result: Result<Self::Output>,
+        cx: &mut WindowContext,
+    ) -> gpui::View<Self::View> {
+        cx.new_view(|_cx| FileBrowserView { result })
+    }
+
+    fn format(_input: &Self::Input, output: &Result<Self::Output>) -> String {
+        let Ok(output) = output else {
+            return "Failed to perform command: {input:?}".to_string();
+        };
+
+        match output {
+            FileBrowserOutput::Ls { entries } => entries.join("\n"),
+            FileBrowserOutput::Cat { content } => content.to_owned(),
+        }
+    }
+}
+
 fn main() {
     env_logger::init();
     App::new().with_assets(Assets).run(|cx| {
@@ -189,11 +306,16 @@ fn main() {
             Task::ready(()),
             cx.background_executor().clone(),
         ));
+
+        let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
         let node_runtime = node_runtime::RealNodeRuntime::new(client.http_client());
         languages::init(language_registry.clone(), node_runtime, cx);
 
         cx.spawn(|cx| async move {
             cx.update(|cx| {
+                let fs = Arc::new(fs::RealFs::new(None));
+                let cwd = std::env::current_dir().expect("Failed to get current working directory");
+
                 cx.open_window(WindowOptions::default(), |cx| {
                     let mut tool_registry = ToolRegistry::new();
                     tool_registry
@@ -201,6 +323,11 @@ fn main() {
                         .context("failed to register DummyTool")
                         .log_err();
 
+                    tool_registry
+                        .register(FileBrowserTool::new(fs, cwd), cx)
+                        .context("failed to register FileBrowserTool")
+                        .log_err();
+
                     let tool_registry = Arc::new(tool_registry);
 
                     println!("Tools registered");
@@ -208,7 +335,7 @@ fn main() {
                         println!("{}", definition);
                     }
 
-                    cx.new_view(|cx| Example::new(language_registry, tool_registry, cx))
+                    cx.new_view(|cx| Example::new(language_registry, tool_registry, user_store, cx))
                 });
                 cx.activate(true);
             })
@@ -225,11 +352,13 @@ impl Example {
     fn new(
         language_registry: Arc<LanguageRegistry>,
         tool_registry: Arc<ToolRegistry>,
+        user_store: Model<UserStore>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         Self {
-            assistant_panel: cx
-                .new_view(|cx| AssistantPanel::new(language_registry, tool_registry, cx)),
+            assistant_panel: cx.new_view(|cx| {
+                AssistantPanel::new(language_registry, tool_registry, user_store, cx)
+            }),
         }
     }
 }

crates/assistant2/examples/file_interactions.rs 🔗

@@ -1,221 +0,0 @@
-//! This example creates a basic Chat UI for interacting with the filesystem.
-
-use anyhow::{Context as _, Result};
-use assets::Assets;
-use assistant2::AssistantPanel;
-use assistant_tooling::{LanguageModelTool, ToolRegistry};
-use client::Client;
-use fs::Fs;
-use futures::StreamExt;
-use gpui::{actions, App, AppContext, KeyBinding, Task, View, WindowOptions};
-use language::LanguageRegistry;
-use project::Project;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use settings::{KeymapFile, DEFAULT_KEYMAP_PATH};
-use std::path::PathBuf;
-use std::sync::Arc;
-use theme::LoadThemes;
-use ui::{div, prelude::*, Render};
-use util::ResultExt as _;
-
-actions!(example, [Quit]);
-
-struct FileBrowserTool {
-    fs: Arc<dyn Fs>,
-    root_dir: PathBuf,
-}
-
-impl FileBrowserTool {
-    fn new(fs: Arc<dyn Fs>, root_dir: PathBuf) -> Self {
-        Self { fs, root_dir }
-    }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
-struct FileBrowserParams {
-    command: FileBrowserCommand,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
-enum FileBrowserCommand {
-    Ls { path: PathBuf },
-    Cat { path: PathBuf },
-}
-
-#[derive(Serialize, Deserialize)]
-enum FileBrowserOutput {
-    Ls { entries: Vec<String> },
-    Cat { content: String },
-}
-
-pub struct FileBrowserView {
-    result: Result<FileBrowserOutput>,
-}
-
-impl Render for FileBrowserView {
-    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let Ok(output) = self.result.as_ref() else {
-            return h_flex().child("Failed to perform operation");
-        };
-
-        match output {
-            FileBrowserOutput::Ls { entries } => v_flex().children(
-                entries
-                    .into_iter()
-                    .map(|entry| h_flex().text_ui(cx).child(entry.clone())),
-            ),
-            FileBrowserOutput::Cat { content } => h_flex().child(content.clone()),
-        }
-    }
-}
-
-impl LanguageModelTool for FileBrowserTool {
-    type Input = FileBrowserParams;
-    type Output = FileBrowserOutput;
-    type View = FileBrowserView;
-
-    fn name(&self) -> String {
-        "file_browser".to_string()
-    }
-
-    fn description(&self) -> String {
-        "A tool for browsing the filesystem.".to_string()
-    }
-
-    fn execute(&self, input: &Self::Input, cx: &AppContext) -> Task<gpui::Result<Self::Output>> {
-        cx.spawn({
-            let fs = self.fs.clone();
-            let root_dir = self.root_dir.clone();
-            let input = input.clone();
-            |_cx| async move {
-                match input.command {
-                    FileBrowserCommand::Ls { path } => {
-                        let path = root_dir.join(path);
-
-                        let mut output = fs.read_dir(&path).await?;
-
-                        let mut entries = Vec::new();
-                        while let Some(entry) = output.next().await {
-                            let entry = entry?;
-                            entries.push(entry.display().to_string());
-                        }
-
-                        Ok(FileBrowserOutput::Ls { entries })
-                    }
-                    FileBrowserCommand::Cat { path } => {
-                        let path = root_dir.join(path);
-
-                        let output = fs.load(&path).await?;
-
-                        Ok(FileBrowserOutput::Cat { content: output })
-                    }
-                }
-            }
-        })
-    }
-
-    fn output_view(
-        _tool_call_id: String,
-        _input: Self::Input,
-        result: Result<Self::Output>,
-        cx: &mut WindowContext,
-    ) -> gpui::View<Self::View> {
-        cx.new_view(|_cx| FileBrowserView { result })
-    }
-
-    fn format(_input: &Self::Input, output: &Result<Self::Output>) -> String {
-        let Ok(output) = output else {
-            return "Failed to perform command: {input:?}".to_string();
-        };
-
-        match output {
-            FileBrowserOutput::Ls { entries } => entries.join("\n"),
-            FileBrowserOutput::Cat { content } => content.to_owned(),
-        }
-    }
-}
-
-fn main() {
-    env_logger::init();
-    App::new().with_assets(Assets).run(|cx| {
-        cx.bind_keys(Some(KeyBinding::new("cmd-q", Quit, None)));
-        cx.on_action(|_: &Quit, cx: &mut AppContext| {
-            cx.quit();
-        });
-
-        settings::init(cx);
-        language::init(cx);
-        Project::init_settings(cx);
-        editor::init(cx);
-        theme::init(LoadThemes::JustBase, cx);
-        Assets.load_fonts(cx).unwrap();
-        KeymapFile::load_asset(DEFAULT_KEYMAP_PATH, cx).unwrap();
-        client::init_settings(cx);
-        release_channel::init("0.130.0", cx);
-
-        let client = Client::production(cx);
-        {
-            let client = client.clone();
-            cx.spawn(|cx| async move { client.authenticate_and_connect(false, &cx).await })
-                .detach_and_log_err(cx);
-        }
-        assistant2::init(client.clone(), cx);
-
-        let language_registry = Arc::new(LanguageRegistry::new(
-            Task::ready(()),
-            cx.background_executor().clone(),
-        ));
-        let node_runtime = node_runtime::RealNodeRuntime::new(client.http_client());
-        languages::init(language_registry.clone(), node_runtime, cx);
-
-        cx.spawn(|cx| async move {
-            cx.update(|cx| {
-                let fs = Arc::new(fs::RealFs::new(None));
-                let cwd = std::env::current_dir().expect("Failed to get current working directory");
-
-                cx.open_window(WindowOptions::default(), |cx| {
-                    let mut tool_registry = ToolRegistry::new();
-                    tool_registry
-                        .register(FileBrowserTool::new(fs, cwd), cx)
-                        .context("failed to register FileBrowserTool")
-                        .log_err();
-
-                    let tool_registry = Arc::new(tool_registry);
-
-                    println!("Tools registered");
-                    for definition in tool_registry.definitions() {
-                        println!("{}", definition);
-                    }
-
-                    cx.new_view(|cx| Example::new(language_registry, tool_registry, cx))
-                });
-                cx.activate(true);
-            })
-        })
-        .detach_and_log_err(cx);
-    })
-}
-
-struct Example {
-    assistant_panel: View<AssistantPanel>,
-}
-
-impl Example {
-    fn new(
-        language_registry: Arc<LanguageRegistry>,
-        tool_registry: Arc<ToolRegistry>,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        Self {
-            assistant_panel: cx
-                .new_view(|cx| AssistantPanel::new(language_registry, tool_registry, cx)),
-        }
-    }
-}
-
-impl Render for Example {
-    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl ui::prelude::IntoElement {
-        div().size_full().child(self.assistant_panel.clone())
-    }
-}

crates/assistant2/src/assistant2.rs 🔗

@@ -1,17 +1,19 @@
 mod assistant_settings;
 mod completion_provider;
 pub mod tools;
+mod ui;
 
+use ::ui::{div, prelude::*, Color, ViewContext};
 use anyhow::{Context, Result};
 use assistant_tooling::{ToolFunctionCall, ToolRegistry};
-use client::{proto, Client};
+use client::{proto, Client, UserStore};
 use completion_provider::*;
 use editor::Editor;
 use feature_flags::FeatureFlagAppExt as _;
 use futures::{future::join_all, StreamExt};
 use gpui::{
-    list, prelude::*, AnyElement, AppContext, AsyncWindowContext, EventEmitter, FocusHandle,
-    FocusableView, ListAlignment, ListState, Render, Task, View, WeakView,
+    list, AnyElement, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, FocusableView,
+    ListAlignment, ListState, Model, Render, Task, View, WeakView,
 };
 use language::{language_settings::SoftWrap, LanguageRegistry};
 use open_ai::{FunctionContent, ToolCall, ToolCallContent};
@@ -22,7 +24,7 @@ use settings::Settings;
 use std::sync::Arc;
 use theme::ThemeSettings;
 use tools::ProjectIndexTool;
-use ui::{popover_menu, prelude::*, ButtonLike, Color, ContextMenu, Tooltip};
+use ui::Composer;
 use util::{paths::EMBEDDINGS_DIR, ResultExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
@@ -101,6 +103,8 @@ impl AssistantPanel {
                 (workspace.app_state().clone(), workspace.project().clone())
             })?;
 
+            let user_store = app_state.user_store.clone();
+
             cx.new_view(|cx| {
                 // todo!("this will panic if the semantic index failed to load or has not loaded yet")
                 let project_index = cx.update_global(|semantic_index: &mut SemanticIndex, cx| {
@@ -118,7 +122,7 @@ impl AssistantPanel {
 
                 let tool_registry = Arc::new(tool_registry);
 
-                Self::new(app_state.languages.clone(), tool_registry, cx)
+                Self::new(app_state.languages.clone(), tool_registry, user_store, cx)
             })
         })
     }
@@ -126,10 +130,16 @@ impl AssistantPanel {
     pub fn new(
         language_registry: Arc<LanguageRegistry>,
         tool_registry: Arc<ToolRegistry>,
+        user_store: Model<UserStore>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let chat = cx.new_view(|cx| {
-            AssistantChat::new(language_registry.clone(), tool_registry.clone(), cx)
+            AssistantChat::new(
+                language_registry.clone(),
+                tool_registry.clone(),
+                user_store,
+                cx,
+            )
         });
 
         Self { width: None, chat }
@@ -174,7 +184,7 @@ impl Panel for AssistantPanel {
         cx.notify();
     }
 
-    fn icon(&self, _cx: &WindowContext) -> Option<ui::IconName> {
+    fn icon(&self, _cx: &WindowContext) -> Option<::ui::IconName> {
         Some(IconName::Ai)
     }
 
@@ -191,13 +201,7 @@ impl EventEmitter<PanelEvent> for AssistantPanel {}
 
 impl FocusableView for AssistantPanel {
     fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
-        self.chat
-            .read(cx)
-            .messages
-            .iter()
-            .rev()
-            .find_map(|msg| msg.focus_handle(cx))
-            .expect("no user message in chat")
+        self.chat.read(cx).composer_editor.read(cx).focus_handle(cx)
     }
 }
 
@@ -206,6 +210,8 @@ struct AssistantChat {
     messages: Vec<ChatMessage>,
     list_state: ListState,
     language_registry: Arc<LanguageRegistry>,
+    composer_editor: View<Editor>,
+    user_store: Model<UserStore>,
     next_message_id: MessageId,
     pending_completion: Option<Task<()>>,
     tool_registry: Arc<ToolRegistry>,
@@ -215,6 +221,7 @@ impl AssistantChat {
     fn new(
         language_registry: Arc<LanguageRegistry>,
         tool_registry: Arc<ToolRegistry>,
+        user_store: Model<UserStore>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let model = CompletionProvider::get(cx).default_model();
@@ -229,17 +236,22 @@ impl AssistantChat {
             },
         );
 
-        let mut this = Self {
+        Self {
             model,
             messages: Vec::new(),
+            composer_editor: cx.new_view(|cx| {
+                let mut editor = Editor::auto_height(80, cx);
+                editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
+                editor.set_placeholder_text("Type a message to the assistant", cx);
+                editor
+            }),
             list_state,
+            user_store,
             language_registry,
             next_message_id: MessageId(0),
             pending_completion: None,
             tool_registry,
-        };
-        this.push_new_user_message(true, cx);
-        this
+        }
     }
 
     fn focused_message_id(&self, cx: &WindowContext) -> Option<MessageId> {
@@ -262,19 +274,37 @@ impl AssistantChat {
         if let Some(ChatMessage::Assistant(message)) = self.messages.last() {
             if message.body.text.is_empty() {
                 self.pop_message(cx);
-            } else {
-                self.push_new_user_message(false, cx);
             }
         }
     }
 
     fn submit(&mut self, Submit(mode): &Submit, cx: &mut ViewContext<Self>) {
-        let Some(focused_message_id) = self.focused_message_id(cx) else {
-            log::error!("unexpected state: no user message editor is focused.");
+        // Don't allow multiple concurrent completions.
+        if self.pending_completion.is_some() {
+            cx.propagate();
             return;
-        };
+        }
 
-        self.truncate_messages(focused_message_id, cx);
+        if let Some(focused_message_id) = self.focused_message_id(cx) {
+            self.truncate_messages(focused_message_id, cx);
+        } else if self.composer_editor.focus_handle(cx).is_focused(cx) {
+            let message = self.composer_editor.update(cx, |composer_editor, cx| {
+                let text = composer_editor.text(cx);
+                let id = self.next_message_id.post_inc();
+                let body = cx.new_view(|cx| {
+                    let mut editor = Editor::auto_height(80, cx);
+                    editor.set_text(text, cx);
+                    editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
+                    editor
+                });
+                composer_editor.clear(cx);
+                ChatMessage::User(UserMessage { id, body })
+            });
+            self.push_message(message, cx);
+        } else {
+            log::error!("unexpected state: no user message editor is focused.");
+            return;
+        }
 
         let mode = *mode;
         self.pending_completion = Some(cx.spawn(move |this, mut cx| async move {
@@ -288,12 +318,8 @@ impl AssistantChat {
             .log_err();
 
             this.update(&mut cx, |this, cx| {
-                let focus = this
-                    .user_message(focused_message_id)
-                    .body
-                    .focus_handle(cx)
-                    .contains_focused(cx);
-                this.push_new_user_message(focus, cx);
+                let composer_focus_handle = this.composer_editor.focus_handle(cx);
+                cx.focus(&composer_focus_handle);
                 this.pending_completion = None;
             })
             .context("Failed to push new user message")
@@ -301,6 +327,10 @@ impl AssistantChat {
         }));
     }
 
+    fn can_submit(&self) -> bool {
+        self.pending_completion.is_none()
+    }
+
     async fn request_completion(
         this: WeakView<Self>,
         mode: SubmitMode,
@@ -424,32 +454,6 @@ impl AssistantChat {
         }
     }
 
-    fn user_message(&mut self, message_id: MessageId) -> &mut UserMessage {
-        self.messages
-            .iter_mut()
-            .find_map(|message| match message {
-                ChatMessage::User(user_message) if user_message.id == message_id => {
-                    Some(user_message)
-                }
-                _ => None,
-            })
-            .expect("User message not found")
-    }
-
-    fn push_new_user_message(&mut self, focus: bool, cx: &mut ViewContext<Self>) {
-        let id = self.next_message_id.post_inc();
-        let body = cx.new_view(|cx| {
-            let mut editor = Editor::auto_height(80, cx);
-            editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
-            if focus {
-                cx.focus_self();
-            }
-            editor
-        });
-        let message = ChatMessage::User(UserMessage { id, body });
-        self.push_message(message, cx);
-    }
-
     fn push_new_assistant_message(&mut self, cx: &mut ViewContext<Self>) {
         let message = ChatMessage::Assistant(AssistantMessage {
             id: self.next_message_id.post_inc(),
@@ -525,7 +529,6 @@ impl AssistantChat {
                 .child(div().p_2().child(Label::new("You").color(Color::Default)))
                 .child(
                     div()
-                        .on_action(cx.listener(Self::submit))
                         .p_2()
                         .text_color(cx.theme().colors().editor_foreground)
                         .font(ThemeSettings::get_global(cx).buffer_font.clone())
@@ -637,59 +640,6 @@ impl AssistantChat {
 
         completion_messages
     }
-
-    fn render_model_dropdown(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let this = cx.view().downgrade();
-        div().h_flex().justify_end().child(
-            div().w_32().child(
-                popover_menu("user-menu")
-                    .menu(move |cx| {
-                        ContextMenu::build(cx, |mut menu, cx| {
-                            for model in CompletionProvider::get(cx).available_models() {
-                                menu = menu.custom_entry(
-                                    {
-                                        let model = model.clone();
-                                        move |_| Label::new(model.clone()).into_any_element()
-                                    },
-                                    {
-                                        let this = this.clone();
-                                        move |cx| {
-                                            _ = this.update(cx, |this, cx| {
-                                                this.model = model.clone();
-                                                cx.notify();
-                                            });
-                                        }
-                                    },
-                                );
-                            }
-                            menu
-                        })
-                        .into()
-                    })
-                    .trigger(
-                        ButtonLike::new("active-model")
-                            .child(
-                                h_flex()
-                                    .w_full()
-                                    .gap_0p5()
-                                    .child(
-                                        div()
-                                            .overflow_x_hidden()
-                                            .flex_grow()
-                                            .whitespace_nowrap()
-                                            .child(Label::new(self.model.clone())),
-                                    )
-                                    .child(div().child(
-                                        Icon::new(IconName::ChevronDown).color(Color::Muted),
-                                    )),
-                            )
-                            .style(ButtonStyle::Subtle)
-                            .tooltip(move |cx| Tooltip::text("Change Model", cx)),
-                    )
-                    .anchor(gpui::AnchorCorner::TopRight),
-            ),
-        )
-    }
 }
 
 impl Render for AssistantChat {
@@ -699,20 +649,22 @@ impl Render for AssistantChat {
             .flex_1()
             .v_flex()
             .key_context("AssistantChat")
+            .on_action(cx.listener(Self::submit))
             .on_action(cx.listener(Self::cancel))
             .text_color(Color::Default.color(cx))
-            .child(self.render_model_dropdown(cx))
             .child(list(self.list_state.clone()).flex_1())
-            .child(
-                h_flex()
-                    .mt_2()
-                    .gap_2()
-                    .children(self.tool_registry.status_views().iter().cloned()),
-            )
+            .child(Composer::new(
+                cx.view().downgrade(),
+                self.model.clone(),
+                self.composer_editor.clone(),
+                self.user_store.read(cx).current_user(),
+                self.can_submit(),
+                self.tool_registry.clone(),
+            ))
     }
 }
 
-#[derive(Copy, Clone, Eq, PartialEq)]
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
 struct MessageId(usize);
 
 impl MessageId {

crates/assistant2/src/ui/composer.rs 🔗

@@ -0,0 +1,222 @@
+use assistant_tooling::ToolRegistry;
+use client::User;
+use editor::{Editor, EditorElement, EditorStyle};
+use gpui::{FontStyle, FontWeight, TextStyle, View, WeakView, WhiteSpace};
+use settings::Settings;
+use std::sync::Arc;
+use theme::ThemeSettings;
+use ui::{popover_menu, prelude::*, Avatar, ButtonLike, ContextMenu, Tooltip};
+
+use crate::{AssistantChat, CompletionProvider, Submit, SubmitMode};
+
+#[derive(IntoElement)]
+pub struct Composer {
+    assistant_chat: WeakView<AssistantChat>,
+    model: String,
+    editor: View<Editor>,
+    player: Option<Arc<User>>,
+    can_submit: bool,
+    tool_registry: Arc<ToolRegistry>,
+}
+
+impl Composer {
+    pub fn new(
+        assistant_chat: WeakView<AssistantChat>,
+        model: String,
+        editor: View<Editor>,
+        player: Option<Arc<User>>,
+        can_submit: bool,
+        tool_registry: Arc<ToolRegistry>,
+    ) -> Self {
+        Self {
+            assistant_chat,
+            model,
+            editor,
+            player,
+            can_submit,
+            tool_registry,
+        }
+    }
+}
+
+impl RenderOnce for Composer {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let mut player_avatar = div().size(rems(20.0 / 16.0)).into_any_element();
+        if let Some(player) = self.player.clone() {
+            player_avatar = Avatar::new(player.avatar_uri.clone())
+                .size(rems(20.0 / 16.0))
+                .into_any_element();
+        }
+
+        let font_size = rems(0.875);
+        let line_height = font_size.to_pixels(cx.rem_size()) * 1.3;
+
+        h_flex()
+            .w_full()
+            .items_start()
+            .mt_4()
+            .gap_3()
+            .child(player_avatar)
+            .child(
+                v_flex()
+                    .size_full()
+                    .gap_1()
+                    .child(
+                        v_flex()
+                            .w_full()
+                            .p_4()
+                            .bg(cx.theme().colors().editor_background)
+                            .rounded_lg()
+                            .child(
+                                v_flex()
+                                    .justify_between()
+                                    .w_full()
+                                    .gap_1()
+                                    .min_h(line_height * 4 + px(74.0))
+                                    .child({
+                                        let settings = ThemeSettings::get_global(cx);
+                                        let text_style = TextStyle {
+                                            color: cx.theme().colors().editor_foreground,
+                                            font_family: settings.buffer_font.family.clone(),
+                                            font_features: settings.buffer_font.features.clone(),
+                                            font_size: font_size.into(),
+                                            font_weight: FontWeight::NORMAL,
+                                            font_style: FontStyle::Normal,
+                                            line_height: line_height.into(),
+                                            background_color: None,
+                                            underline: None,
+                                            strikethrough: None,
+                                            white_space: WhiteSpace::Normal,
+                                        };
+
+                                        EditorElement::new(
+                                            &self.editor,
+                                            EditorStyle {
+                                                background: cx.theme().colors().editor_background,
+                                                local_player: cx.theme().players().local(),
+                                                text: text_style,
+                                                ..Default::default()
+                                            },
+                                        )
+                                    })
+                                    .child(
+                                        h_flex()
+                                            .flex_none()
+                                            .gap_2()
+                                            .justify_between()
+                                            .w_full()
+                                            .child(
+                                                h_flex().gap_1().child(
+                                                    // IconButton/button
+                                                    // Toggle - if enabled, .selected(true).selected_style(IconButtonStyle::Filled)
+                                                    //
+                                                    // match status
+                                                    // Tooltip::with_meta("some label explaining project index + status", "click to enable")
+                                                    IconButton::new(
+                                                        "add-context",
+                                                        IconName::FileDoc,
+                                                    )
+                                                    .icon_color(Color::Muted),
+                                                ), // .child(
+                                                   //     IconButton::new(
+                                                   //         "add-context",
+                                                   //         IconName::Plus,
+                                                   //     )
+                                                   //     .icon_color(Color::Muted),
+                                                   // ),
+                                            )
+                                            .child(
+                                                Button::new("send-button", "Send")
+                                                    .style(ButtonStyle::Filled)
+                                                    .disabled(!self.can_submit)
+                                                    .on_click(|_, cx| {
+                                                        cx.dispatch_action(Box::new(Submit(
+                                                            SubmitMode::Codebase,
+                                                        )))
+                                                    })
+                                                    .tooltip(|cx| {
+                                                        Tooltip::for_action(
+                                                            "Submit message",
+                                                            &Submit(SubmitMode::Codebase),
+                                                            cx,
+                                                        )
+                                                    }),
+                                            ),
+                                    ),
+                            ),
+                    )
+                    .child(
+                        h_flex()
+                            .w_full()
+                            .justify_between()
+                            .child(ModelSelector::new(self.assistant_chat, self.model))
+                            .children(self.tool_registry.status_views().iter().cloned()),
+                    ),
+            )
+    }
+}
+
+#[derive(IntoElement)]
+struct ModelSelector {
+    assistant_chat: WeakView<AssistantChat>,
+    model: String,
+}
+
+impl ModelSelector {
+    pub fn new(assistant_chat: WeakView<AssistantChat>, model: String) -> Self {
+        Self {
+            assistant_chat,
+            model,
+        }
+    }
+}
+
+impl RenderOnce for ModelSelector {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
+        popover_menu("model-switcher")
+            .menu(move |cx| {
+                ContextMenu::build(cx, |mut menu, cx| {
+                    for model in CompletionProvider::get(cx).available_models() {
+                        menu = menu.custom_entry(
+                            {
+                                let model = model.clone();
+                                move |_| Label::new(model.clone()).into_any_element()
+                            },
+                            {
+                                let assistant_chat = self.assistant_chat.clone();
+                                move |cx| {
+                                    _ = assistant_chat.update(cx, |assistant_chat, cx| {
+                                        assistant_chat.model = model.clone();
+                                        cx.notify();
+                                    });
+                                }
+                            },
+                        );
+                    }
+                    menu
+                })
+                .into()
+            })
+            .trigger(
+                ButtonLike::new("active-model")
+                    .child(
+                        h_flex()
+                            .w_full()
+                            .gap_0p5()
+                            .child(
+                                div()
+                                    .overflow_x_hidden()
+                                    .flex_grow()
+                                    .whitespace_nowrap()
+                                    .child(Label::new(self.model)),
+                            )
+                            .child(
+                                div().child(Icon::new(IconName::ChevronDown).color(Color::Muted)),
+                            ),
+                    )
+                    .style(ButtonStyle::Subtle)
+                    .tooltip(move |cx| Tooltip::text("Change Model", cx)),
+            )
+            .anchor(gpui::AnchorCorner::BottomRight)
+    }
+}