From 9a2ed297dd99e24a12333f7c0d582a44d14cb253 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 19 Mar 2026 12:11:05 +0100 Subject: [PATCH] agent_ui: Fix panic in message editor (#51918) ## Context Fixes ZED-59M We could end panicking because of a double lease in message editor. This could happen when pasting text and this line was executed: `PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))` Since self.source is the Entity in this case, we will try to read message editor while it's being updated. I took this as an opportunity to refactor `prompt_capabilities` and `available_commands` which were both passed around as Rc>. Now we have a single struct called `SessionCapabilities` which maintains both and we pass that around wrapped in an `Arc>` (`SharedSessionCapabilities`) ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed a crash when pasting text into the prompt editor in the agent panel --- crates/agent_ui/src/conversation_view.rs | 42 +- .../src/conversation_view/thread_view.rs | 25 +- crates/agent_ui/src/entry_view_state.rs | 24 +- crates/agent_ui/src/message_editor.rs | 361 +++++++++++++----- 4 files changed, 299 insertions(+), 153 deletions(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index b703d00c4b333de257bf224835a63453ab897440..1f9a7cdd9316db3ce0dda882a0433e06df287a57 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -7,7 +7,7 @@ use acp_thread::{ use acp_thread::{AgentConnection, Plan}; use action_log::{ActionLog, ActionLogTelemetry, DiffStats}; use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore}; -use agent_client_protocol::{self as acp, PromptCapabilities}; +use agent_client_protocol as acp; use agent_servers::AgentServer; #[cfg(test)] use agent_servers::AgentServerDelegate; @@ -36,11 +36,13 @@ use gpui::{ use language::Buffer; use language_model::LanguageModelRegistry; use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle}; +use parking_lot::RwLock; use project::{AgentId, AgentServerStore, Project, ProjectEntryId}; use prompt_store::{PromptId, PromptStore}; + +use crate::message_editor::SessionCapabilities; use rope::Point; use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; -use std::cell::RefCell; use std::path::Path; use std::sync::Arc; use std::time::Instant; @@ -588,11 +590,7 @@ impl ConversationView { if let Some(view) = self.active_thread() { view.update(cx, |this, cx| { this.message_editor.update(cx, |editor, cx| { - editor.set_command_state( - this.prompt_capabilities.clone(), - this.available_commands.clone(), - cx, - ); + editor.set_session_capabilities(this.session_capabilities.clone(), cx); }); }); } @@ -821,13 +819,13 @@ impl ConversationView { cx: &mut Context, ) -> Entity { let agent_id = self.agent.agent_id(); - let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); - let available_commands = Rc::new(RefCell::new(vec![])); + let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new( + thread.read(cx).prompt_capabilities(), + vec![], + ))); let action_log = thread.read(cx).action_log().clone(); - prompt_capabilities.replace(thread.read(cx).prompt_capabilities()); - let entry_view_state = cx.new(|_| { EntryViewState::new( self.workspace.clone(), @@ -835,8 +833,7 @@ impl ConversationView { self.thread_store.clone(), history.as_ref().map(|h| h.downgrade()), self.prompt_store.clone(), - prompt_capabilities.clone(), - available_commands.clone(), + session_capabilities.clone(), self.agent.agent_id(), ) }); @@ -995,8 +992,7 @@ impl ConversationView { model_selector, profile_selector, list_state, - prompt_capabilities, - available_commands, + session_capabilities, resumed_without_history, self.project.downgrade(), self.thread_store.clone(), @@ -1411,8 +1407,9 @@ impl ConversationView { if let Some(active) = self.thread_view(&thread_id) { active.update(cx, |active, _cx| { active - .prompt_capabilities - .replace(thread.read(_cx).prompt_capabilities()); + .session_capabilities + .write() + .set_prompt_capabilities(thread.read(_cx).prompt_capabilities()); }); } } @@ -1437,7 +1434,10 @@ impl ConversationView { let has_commands = !available_commands.is_empty(); if let Some(active) = self.active_thread() { active.update(cx, |active, _cx| { - active.available_commands.replace(available_commands); + active + .session_capabilities + .write() + .set_available_commands(available_commands); }); } @@ -2217,8 +2217,7 @@ impl ConversationView { let Some(thread) = connected.active_view() else { return; }; - let prompt_capabilities = thread.read(cx).prompt_capabilities.clone(); - let available_commands = thread.read(cx).available_commands.clone(); + let session_capabilities = thread.read(cx).session_capabilities.clone(); let current_count = thread.read(cx).queued_message_editors.len(); let last_synced = thread.read(cx).last_synced_queue_length; @@ -2257,8 +2256,7 @@ impl ConversationView { None, history.clone(), None, - prompt_capabilities.clone(), - available_commands.clone(), + session_capabilities.clone(), agent_name.clone(), "", EditorMode::AutoHeight { diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 4d20b08a7adc1ab9c07da9b28237b6029a1bb3db..2f08070ed77aaa7e403e1fe131a6b82f1acb13e8 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1,8 +1,11 @@ +use std::cell::RefCell; + use acp_thread::ContentBlock; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; use crate::StartThreadIn; +use crate::message_editor::SharedSessionCapabilities; use gpui::{Corner, List}; use language_model::{LanguageModelEffortLevel, Speed}; use settings::update_settings_file; @@ -187,8 +190,7 @@ pub struct ThreadView { pub last_token_limit_telemetry: Option, thread_feedback: ThreadFeedbackState, pub list_state: ListState, - pub prompt_capabilities: Rc>, - pub available_commands: Rc>>, + pub session_capabilities: SharedSessionCapabilities, /// Tracks which tool calls have their content/output expanded. /// Used for showing/hiding tool call results, terminal output, etc. pub expanded_tool_calls: HashSet, @@ -268,8 +270,7 @@ impl ThreadView { model_selector: Option>, profile_selector: Option>, list_state: ListState, - prompt_capabilities: Rc>, - available_commands: Rc>>, + session_capabilities: SharedSessionCapabilities, resumed_without_history: bool, project: WeakEntity, thread_store: Option>, @@ -300,8 +301,7 @@ impl ThreadView { thread_store, history.as_ref().map(|h| h.downgrade()), prompt_store, - prompt_capabilities.clone(), - available_commands.clone(), + session_capabilities.clone(), agent_id.clone(), &placeholder, editor::EditorMode::AutoHeight { @@ -417,8 +417,7 @@ impl ThreadView { model_selector, profile_selector, list_state, - prompt_capabilities, - available_commands, + session_capabilities, resumed_without_history, _subscriptions: subscriptions, permission_dropdown_handle: PopoverMenuHandle::default(), @@ -874,8 +873,9 @@ impl ThreadView { // Does the agent have a specific logout command? Prefer that in case they need to reset internal state. let logout_supported = text == "/logout" && self - .available_commands - .borrow() + .session_capabilities + .read() + .available_commands() .iter() .any(|command| command.name == "logout"); if can_login && !logout_supported { @@ -3575,8 +3575,9 @@ impl ThreadView { ) -> Entity { let message_editor = self.message_editor.clone(); let workspace = self.workspace.clone(); - let supports_images = self.prompt_capabilities.borrow().image; - let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context; + let session_capabilities = self.session_capabilities.read(); + let supports_images = session_capabilities.supports_images(); + let supports_embedded_context = session_capabilities.supports_embedded_context(); let has_editor_selection = workspace .upgrade() diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index b133c27aa78e5ba0663f8dadf763641aa1d2bcfa..ef5e8a9812e8266566f027365e4b270177aab71c 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -1,9 +1,9 @@ -use std::{cell::RefCell, ops::Range, rc::Rc}; +use std::ops::Range; use super::thread_history::ThreadHistory; use acp_thread::{AcpThread, AgentThreadEntry}; use agent::ThreadStore; -use agent_client_protocol::{self as acp, ToolCallId}; +use agent_client_protocol::ToolCallId; use collections::HashMap; use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior}; use gpui::{ @@ -20,7 +20,7 @@ use theme::ThemeSettings; use ui::{Context, TextSize}; use workspace::Workspace; -use crate::message_editor::{MessageEditor, MessageEditorEvent}; +use crate::message_editor::{MessageEditor, MessageEditorEvent, SharedSessionCapabilities}; pub struct EntryViewState { workspace: WeakEntity, @@ -29,8 +29,7 @@ pub struct EntryViewState { history: Option>, prompt_store: Option>, entries: Vec, - prompt_capabilities: Rc>, - available_commands: Rc>>, + session_capabilities: SharedSessionCapabilities, agent_id: AgentId, } @@ -41,8 +40,7 @@ impl EntryViewState { thread_store: Option>, history: Option>, prompt_store: Option>, - prompt_capabilities: Rc>, - available_commands: Rc>>, + session_capabilities: SharedSessionCapabilities, agent_id: AgentId, ) -> Self { Self { @@ -52,8 +50,7 @@ impl EntryViewState { history, prompt_store, entries: Vec::new(), - prompt_capabilities, - available_commands, + session_capabilities, agent_id, } } @@ -94,8 +91,7 @@ impl EntryViewState { self.thread_store.clone(), self.history.clone(), self.prompt_store.clone(), - self.prompt_capabilities.clone(), - self.available_commands.clone(), + self.session_capabilities.clone(), self.agent_id.clone(), "Edit message - @ to include context", editor::EditorMode::AutoHeight { @@ -458,6 +454,7 @@ fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement { mod tests { use std::path::Path; use std::rc::Rc; + use std::sync::Arc; use acp_thread::{AgentConnection, StubAgentConnection}; use agent_client_protocol as acp; @@ -465,8 +462,10 @@ mod tests { use editor::RowInfo; use fs::FakeFs; use gpui::{AppContext as _, TestAppContext}; + use parking_lot::RwLock; use crate::entry_view_state::EntryViewState; + use crate::message_editor::SessionCapabilities; use multi_buffer::MultiBufferRow; use pretty_assertions::assert_matches; use project::Project; @@ -524,8 +523,7 @@ mod tests { thread_store, history, None, - Default::default(), - Default::default(), + Arc::new(RwLock::new(SessionCapabilities::default())), "Test Agent".into(), ) }); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 8ad880bec06bcc8fd77c0548966bbb900d5f39b9..f8329301493728a51a71bba4fe455168265a3a41 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -26,12 +26,13 @@ use gpui::{ KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, }; use language::{Buffer, Language, language_settings::InlayHintKind}; +use parking_lot::RwLock; use project::AgentId; use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree}; use prompt_store::PromptStore; use rope::Point; use settings::Settings; -use std::{cell::RefCell, fmt::Write, ops::Range, rc::Rc, sync::Arc}; +use std::{fmt::Write, ops::Range, rc::Rc, sync::Arc}; use theme::ThemeSettings; use ui::{ContextMenu, Disclosure, ElevationIndex, prelude::*}; use util::paths::PathStyle; @@ -39,41 +40,39 @@ use util::{ResultExt, debug_panic}; use workspace::{CollaboratorId, Workspace}; use zed_actions::agent::{Chat, PasteRaw}; -pub struct MessageEditor { - mention_set: Entity, - editor: Entity, - workspace: WeakEntity, - prompt_capabilities: Rc>, - available_commands: Rc>>, - agent_id: AgentId, - thread_store: Option>, - _subscriptions: Vec, - _parse_slash_command_task: Task<()>, +#[derive(Default)] +pub struct SessionCapabilities { + prompt_capabilities: acp::PromptCapabilities, + available_commands: Vec, } -#[derive(Clone, Debug)] -pub enum MessageEditorEvent { - Send, - SendImmediately, - Cancel, - Focus, - LostFocus, - InputAttempted(Arc), -} +impl SessionCapabilities { + pub fn new( + prompt_capabilities: acp::PromptCapabilities, + available_commands: Vec, + ) -> Self { + Self { + prompt_capabilities, + available_commands, + } + } -impl EventEmitter for MessageEditor {} + pub fn supports_images(&self) -> bool { + self.prompt_capabilities.image + } -const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0); + pub fn supports_embedded_context(&self) -> bool { + self.prompt_capabilities.embedded_context + } -impl PromptCompletionProviderDelegate for Entity { - fn supports_images(&self, cx: &App) -> bool { - self.read(cx).prompt_capabilities.borrow().image + pub fn available_commands(&self) -> &[acp::AvailableCommand] { + &self.available_commands } - fn supported_modes(&self, cx: &App) -> Vec { + fn supported_modes(&self, has_thread_store: bool) -> Vec { let mut supported = vec![PromptContextType::File, PromptContextType::Symbol]; - if self.read(cx).prompt_capabilities.borrow().embedded_context { - if self.read(cx).thread_store.is_some() { + if self.prompt_capabilities.embedded_context { + if has_thread_store { supported.push(PromptContextType::Thread); } supported.extend(&[ @@ -86,10 +85,8 @@ impl PromptCompletionProviderDelegate for Entity { supported } - fn available_commands(&self, cx: &App) -> Vec { - self.read(cx) - .available_commands - .borrow() + pub fn completion_commands(&self) -> Vec { + self.available_commands .iter() .map(|cmd| crate::completion_provider::AvailableCommand { name: cmd.name.clone().into(), @@ -99,11 +96,68 @@ impl PromptCompletionProviderDelegate for Entity { .collect() } + pub fn set_prompt_capabilities(&mut self, prompt_capabilities: acp::PromptCapabilities) { + self.prompt_capabilities = prompt_capabilities; + } + + pub fn set_available_commands(&mut self, available_commands: Vec) { + self.available_commands = available_commands; + } +} + +pub type SharedSessionCapabilities = Arc>; + +struct MessageEditorCompletionDelegate { + session_capabilities: SharedSessionCapabilities, + has_thread_store: bool, + message_editor: WeakEntity, +} + +impl PromptCompletionProviderDelegate for MessageEditorCompletionDelegate { + fn supports_images(&self, _cx: &App) -> bool { + self.session_capabilities.read().supports_images() + } + + fn supported_modes(&self, _cx: &App) -> Vec { + self.session_capabilities + .read() + .supported_modes(self.has_thread_store) + } + + fn available_commands(&self, _cx: &App) -> Vec { + self.session_capabilities.read().completion_commands() + } + fn confirm_command(&self, cx: &mut App) { - self.update(cx, |this, cx| this.send(cx)); + let _ = self.message_editor.update(cx, |this, cx| this.send(cx)); } } +pub struct MessageEditor { + mention_set: Entity, + editor: Entity, + workspace: WeakEntity, + session_capabilities: SharedSessionCapabilities, + agent_id: AgentId, + thread_store: Option>, + _subscriptions: Vec, + _parse_slash_command_task: Task<()>, +} + +#[derive(Clone, Debug)] +pub enum MessageEditorEvent { + Send, + SendImmediately, + Cancel, + Focus, + LostFocus, + InputAttempted(Arc), +} + +impl EventEmitter for MessageEditor {} + +const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0); + impl MessageEditor { pub fn new( workspace: WeakEntity, @@ -111,8 +165,7 @@ impl MessageEditor { thread_store: Option>, history: Option>, prompt_store: Option>, - prompt_capabilities: Rc>, - available_commands: Rc>>, + session_capabilities: SharedSessionCapabilities, agent_id: AgentId, placeholder: &str, mode: EditorMode, @@ -164,7 +217,11 @@ impl MessageEditor { let mention_set = cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone())); let completion_provider = Rc::new(PromptCompletionProvider::new( - cx.entity(), + MessageEditorCompletionDelegate { + session_capabilities: session_capabilities.clone(), + has_thread_store: thread_store.is_some(), + message_editor: cx.weak_entity(), + }, editor.downgrade(), mention_set.clone(), history, @@ -234,8 +291,7 @@ impl MessageEditor { editor, mention_set, workspace, - prompt_capabilities, - available_commands, + session_capabilities, agent_id, thread_store, _subscriptions: subscriptions, @@ -243,18 +299,17 @@ impl MessageEditor { } } - pub fn set_command_state( + pub fn set_session_capabilities( &mut self, - prompt_capabilities: Rc>, - available_commands: Rc>>, + session_capabilities: SharedSessionCapabilities, _cx: &mut Context, ) { - self.prompt_capabilities = prompt_capabilities; - self.available_commands = available_commands; + self.session_capabilities = session_capabilities; } fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option { - let available_commands = self.available_commands.borrow(); + let session_capabilities = self.session_capabilities.read(); + let available_commands = session_capabilities.available_commands(); if available_commands.is_empty() { return None; } @@ -334,7 +389,7 @@ impl MessageEditor { .text_anchor }); - let supports_images = self.prompt_capabilities.borrow().image; + let supports_images = self.session_capabilities.read().supports_images(); self.mention_set .update(cx, |mention_set, cx| { @@ -415,7 +470,11 @@ impl MessageEditor { cx: &mut Context, ) -> Task, Vec>)>> { let text = self.editor.read(cx).text(cx); - let available_commands = self.available_commands.borrow().clone(); + let available_commands = self + .session_capabilities + .read() + .available_commands() + .to_vec(); let agent_id = self.agent_id.clone(); let build_task = self.build_content_blocks(full_mention_content, cx); @@ -442,7 +501,8 @@ impl MessageEditor { .mention_set .update(cx, |store, cx| store.contents(full_mention_content, cx)); let editor = self.editor.clone(); - let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context; + let supports_embedded_context = + self.session_capabilities.read().supports_embedded_context(); cx.spawn(async move |_, cx| { let contents = contents.await?; @@ -822,7 +882,7 @@ impl MessageEditor { } if !all_mentions.is_empty() { - let supports_images = self.prompt_capabilities.borrow().image; + let supports_images = self.session_capabilities.read().supports_images(); let http_client = workspace.read(cx).client().http_client(); for (anchor, content_len, mention_uri) in all_mentions { @@ -881,7 +941,7 @@ impl MessageEditor { }) .unwrap_or(false); - if self.prompt_capabilities.borrow().image + if self.session_capabilities.read().supports_images() && has_non_text_content && let Some(task) = paste_images_as_context( self.editor.clone(), @@ -958,7 +1018,7 @@ impl MessageEditor { cx, ); }); - let supports_images = self.prompt_capabilities.borrow().image; + let supports_images = self.session_capabilities.read().supports_images(); tasks.push(self.mention_set.update(cx, |mention_set, cx| { mention_set.confirm_mention_completion( file_name, @@ -1213,7 +1273,7 @@ impl MessageEditor { return; }; let Some(completion) = - PromptCompletionProvider::>::completion_for_action( + PromptCompletionProvider::::completion_for_action( PromptContextAction::AddSelections, anchor..anchor, self.editor.downgrade(), @@ -1235,7 +1295,7 @@ impl MessageEditor { } pub fn add_images_from_picker(&mut self, window: &mut Window, cx: &mut Context) { - if !self.prompt_capabilities.borrow().image { + if !self.session_capabilities.read().supports_images() { return; } @@ -1662,7 +1722,7 @@ fn find_matching_bracket(text: &str, open: char, close: char) -> Option { #[cfg(test)] mod tests { - use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc}; + use std::{ops::Range, path::Path, sync::Arc}; use acp_thread::MentionUri; use agent::{ThreadStore, outline}; @@ -1680,6 +1740,7 @@ mod tests { }; use language_model::LanguageModelRegistry; use lsp::{CompletionContext, CompletionTriggerKind}; + use parking_lot::RwLock; use project::{CompletionIntent, Project, ProjectPath}; use serde_json::json; @@ -1688,10 +1749,10 @@ mod tests { use util::{path, paths::PathStyle, rel_path::rel_path}; use workspace::{AppState, Item, MultiWorkspace}; - use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType}; + use crate::completion_provider::PromptContextType; use crate::{ conversation_view::tests::init_test, - message_editor::{Mention, MessageEditor, parse_mention_links}, + message_editor::{Mention, MessageEditor, SessionCapabilities, parse_mention_links}, }; #[test] @@ -1809,7 +1870,6 @@ mod tests { None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -1904,9 +1964,10 @@ mod tests { let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; let thread_store = None; - let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); - // Start with no available commands - simulating Claude which doesn't support slash commands - let available_commands = Rc::new(RefCell::new(vec![])); + let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new( + acp::PromptCapabilities::default(), + vec![], + ))); let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); @@ -1920,8 +1981,7 @@ mod tests { thread_store.clone(), None, None, - prompt_capabilities.clone(), - available_commands.clone(), + session_capabilities.clone(), "Claude Agent".into(), "Test", EditorMode::AutoHeight { @@ -1951,7 +2011,9 @@ mod tests { assert!(error_message.contains("Available commands: none")); // Now simulate Claude providing its list of available commands (which doesn't include file) - available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]); + session_capabilities + .write() + .set_available_commands(vec![acp::AvailableCommand::new("help", "Get help")]); // Test that unsupported slash commands trigger an error when we have a list of available commands editor.update_in(cx, |editor, window, cx| { @@ -2065,15 +2127,17 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = None; - let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); - let available_commands = Rc::new(RefCell::new(vec![ - acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"), - acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input( - acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new( - "", - )), - ), - ])); + let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new( + acp::PromptCapabilities::default(), + vec![ + acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"), + acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input( + acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new( + "", + )), + ), + ], + ))); let editor = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -2084,8 +2148,7 @@ mod tests { thread_store.clone(), None, None, - prompt_capabilities.clone(), - available_commands.clone(), + session_capabilities.clone(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -2298,7 +2361,10 @@ mod tests { } let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); + let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new( + acp::PromptCapabilities::default(), + vec![], + ))); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -2309,8 +2375,7 @@ mod tests { Some(thread_store), None, None, - prompt_capabilities.clone(), - Default::default(), + session_capabilities.clone(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -2356,12 +2421,14 @@ mod tests { editor.set_text("", window, cx); }); - prompt_capabilities.replace( - acp::PromptCapabilities::new() - .image(true) - .audio(true) - .embedded_context(true), - ); + message_editor.update(&mut cx, |editor, _cx| { + editor.session_capabilities.write().set_prompt_capabilities( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ); + }); cx.simulate_input("Lorem "); @@ -2802,7 +2869,6 @@ mod tests { None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -2814,8 +2880,9 @@ mod tests { ); // Enable embedded context so files are actually included editor - .prompt_capabilities - .replace(acp::PromptCapabilities::new().embedded_context(true)); + .session_capabilities + .write() + .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true)); editor }) }); @@ -2904,7 +2971,6 @@ mod tests { None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -2975,7 +3041,6 @@ mod tests { None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -3030,7 +3095,6 @@ mod tests { None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -3045,13 +3109,19 @@ mod tests { message_editor.update(cx, |editor, _cx| { editor - .prompt_capabilities - .replace(acp::PromptCapabilities::new().embedded_context(true)); + .session_capabilities + .write() + .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true)); }); let supported_modes = { let app = cx.app.borrow(); - message_editor.supported_modes(&app) + let _ = &app; + message_editor + .read(&app) + .session_capabilities + .read() + .supported_modes(false) }; assert!( @@ -3083,7 +3153,6 @@ mod tests { None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -3098,13 +3167,19 @@ mod tests { message_editor.update(cx, |editor, _cx| { editor - .prompt_capabilities - .replace(acp::PromptCapabilities::new().embedded_context(true)); + .session_capabilities + .write() + .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true)); }); let supported_modes = { let app = cx.app.borrow(); - message_editor.supported_modes(&app) + let _ = &app; + message_editor + .read(&app) + .session_capabilities + .read() + .supported_modes(true) }; assert!( @@ -3137,7 +3212,6 @@ mod tests { None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -3201,12 +3275,11 @@ mod tests { None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { - max_lines: None, min_lines: 1, + max_lines: None, }, window, cx, @@ -3258,8 +3331,9 @@ mod tests { message_editor.update(cx, |editor, _cx| { editor - .prompt_capabilities - .replace(acp::PromptCapabilities::new().embedded_context(true)) + .session_capabilities + .write() + .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true)) }); let content = message_editor @@ -3362,7 +3436,6 @@ mod tests { None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::full(), @@ -3474,11 +3547,10 @@ mod tests { MessageEditor::new( workspace_handle, project.downgrade(), - Some(thread_store), + Some(thread_store.clone()), None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -3559,7 +3631,6 @@ mod tests { None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -3619,6 +3690,86 @@ mod tests { ); } + #[gpui::test] + async fn test_paste_mention_link_with_completion_trigger_does_not_panic( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + editor::init(cx); + workspace::init(app_state.clone(), cx); + }); + + app_state + .fs + .as_fake() + .insert_tree(path!("/project"), json!({"file.txt": "content"})) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await; + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let mut cx = VisualTestContext::from_window(window.into(), cx); + + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + + let (_message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.downgrade(), + Some(thread_store), + None, + None, + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window, cx); + let editor = message_editor.read(cx).editor().clone(); + (message_editor, editor) + }); + + cx.simulate_input("@"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "@"); + assert!(editor.has_visible_completions_menu()); + }); + + cx.write_to_clipboard(ClipboardItem::new_string("[@f](file:///test.txt) @".into())); + cx.dispatch_action(Paste); + + editor.update(&mut cx, |editor, cx| { + assert!(editor.text(cx).contains("[@f](file:///test.txt)")); + }); + } + // Helper that creates a minimal MessageEditor inside a window, returning both // the entity and the underlying VisualTestContext so callers can drive updates. async fn setup_message_editor( @@ -3641,7 +3792,6 @@ mod tests { None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight { @@ -3792,7 +3942,6 @@ mod tests { None, None, Default::default(), - Default::default(), "Test Agent".into(), "Test", EditorMode::AutoHeight {