Merge branch 'main' into fix-worktree-drag-reorder

This commit is contained in:
Elliot Thomas 2026-05-05 16:28:12 +01:00 committed by GitHub
commit 017c649340
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 227 additions and 78 deletions

View file

@ -299,10 +299,15 @@ impl ToolCall {
let subagent_session_info = subagent_session_info_from_meta(&tool_call.meta);
let label = if tool_call.kind == acp::ToolKind::Execute {
cx.new(|cx| Markdown::new_text(title.into(), cx))
} else {
cx.new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx))
};
let result = Self {
id: tool_call.tool_call_id,
label: cx
.new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)),
label,
kind: tool_call.kind,
content,
locations: tool_call.locations,

View file

@ -81,7 +81,7 @@ use crate::agent_connection_store::{
};
use crate::agent_diff::AgentDiff;
use crate::entry_view_state::{EntryViewEvent, ViewEvent};
use crate::message_editor::{MessageEditor, MessageEditorEvent};
use crate::message_editor::{InputAttempt, MessageEditor, MessageEditorEvent};
use crate::profile_selector::{ProfileProvider, ProfileSelector};
use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
@ -1387,7 +1387,7 @@ impl ConversationView {
fn move_queued_message_to_main_editor(
&mut self,
index: usize,
inserted_text: Option<&str>,
attempt: Option<InputAttempt>,
cursor_offset: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
@ -1396,7 +1396,7 @@ impl ConversationView {
active.update(cx, |active, cx| {
active.move_queued_message_to_main_editor(
index,
inserted_text,
attempt,
cursor_offset,
window,
cx,
@ -2384,15 +2384,17 @@ impl ConversationView {
window,
move |this, _editor, event, window, cx| match event {
MessageEditorEvent::InputAttempted {
text,
attempt,
cursor_offset,
} => this.move_queued_message_to_main_editor(
index,
Some(text.as_ref()),
Some(*cursor_offset),
window,
cx,
),
} => {
this.move_queued_message_to_main_editor(
index,
Some(attempt.clone()),
Some(*cursor_offset),
window,
cx,
);
}
MessageEditorEvent::LostFocus => {
this.save_queued_message_at_index(index, cx);
}
@ -2958,8 +2960,9 @@ pub(crate) mod tests {
use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext};
use agent_servers::FakeAcpAgentServer;
use editor::MultiBufferOffset;
use editor::actions::Paste;
use fs::FakeFs;
use gpui::{EventEmitter, TestAppContext, VisualTestContext};
use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext};
use parking_lot::Mutex;
use project::Project;
use serde_json::json;
@ -7405,6 +7408,107 @@ pub(crate) mod tests {
);
}
#[gpui::test]
async fn test_paste_text_into_queued_message_promotes_to_main_editor(cx: &mut TestAppContext) {
init_test(cx);
let (conversation_view, cx) =
paste_into_queued_message(cx, ClipboardItem::new_string("PASTED".to_string())).await;
let queue_len = active_thread(&conversation_view, cx)
.read_with(cx, |thread, _cx| thread.local_queued_messages.len());
assert_eq!(queue_len, 0);
let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
assert_eq!(text, "queued PASTEDmessage");
}
#[gpui::test]
async fn test_paste_image_into_queued_message_promotes_to_main_editor(cx: &mut TestAppContext) {
init_test(cx);
use base64::Engine as _;
use std::io::Write as _;
let png_bytes = base64::prelude::BASE64_STANDARD
.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
.unwrap();
let mut image_file = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
image_file.write_all(&png_bytes).unwrap();
let (conversation_view, cx) = paste_into_queued_message(
cx,
ClipboardItem {
entries: vec![gpui::ClipboardEntry::ExternalPaths(gpui::ExternalPaths(
vec![image_file.path().to_path_buf()].into(),
))],
},
)
.await;
let queue_len = active_thread(&conversation_view, cx)
.read_with(cx, |thread, _cx| thread.local_queued_messages.len());
assert_eq!(queue_len, 0);
let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx));
let image_name = image_file.path().file_name().unwrap().to_string_lossy();
let expected_uri = acp_thread::MentionUri::PastedImage {
name: image_name.to_string(),
}
.to_uri()
.to_string();
assert_eq!(
text,
format!("queued [@{image_name}]({expected_uri}) message"),
);
}
async fn paste_into_queued_message(
cx: &mut TestAppContext,
clipboard: ClipboardItem,
) -> (Entity<ConversationView>, &mut VisualTestContext) {
let (conversation_view, cx) =
setup_conversation_view(StubAgentServer::default_response(), cx).await;
add_to_workspace(conversation_view.clone(), cx);
active_thread(&conversation_view, cx).update_in(cx, |thread, _window, cx| {
thread
.session_capabilities
.write()
.set_prompt_capabilities(acp::PromptCapabilities::new().image(true));
thread.add_to_queue(
vec![acp::ContentBlock::Text(acp::TextContent::new(
"queued message".to_string(),
))],
vec![],
cx,
);
});
conversation_view.update(cx, |_, cx| cx.notify());
cx.run_until_parked();
let queued_editor = active_thread(&conversation_view, cx).read_with(cx, |thread, _cx| {
thread
.queued_message_editors
.first()
.cloned()
.expect("queued message editor not synced")
});
cx.write_to_clipboard(clipboard);
queued_editor.update_in(cx, |message_editor, window, cx| {
message_editor.editor().update(cx, |editor, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
selections.select_ranges([MultiBufferOffset(7)..MultiBufferOffset(7)]);
});
});
message_editor.paste(&Paste, window, cx);
});
cx.run_until_parked();
(conversation_view, cx)
}
#[gpui::test]
async fn test_close_all_sessions_skips_when_unsupported(cx: &mut TestAppContext) {
init_test(cx);

View file

@ -1539,7 +1539,7 @@ impl ThreadView {
pub fn move_queued_message_to_main_editor(
&mut self,
index: usize,
inserted_text: Option<&str>,
attempt: Option<InputAttempt>,
cursor_offset: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
@ -1549,36 +1549,35 @@ impl ThreadView {
};
let queued_content = queued_message.content;
let message_editor = self.message_editor.clone();
let inserted_text = inserted_text.map(ToOwned::to_owned);
window.focus(&message_editor.focus_handle(cx), cx);
if message_editor.read(cx).is_empty(cx) {
let adjusted_cursor_offset = if message_editor.read(cx).is_empty(cx) {
message_editor.update(cx, |editor, cx| {
editor.set_message(queued_content, window, cx);
if let Some(offset) = cursor_offset {
editor.set_cursor_offset(offset, window, cx);
}
if let Some(inserted_text) = inserted_text.as_deref() {
editor.insert_text(inserted_text, window, cx);
}
});
cx.notify();
return true;
}
// Adjust cursor offset accounting for existing content
let existing_len = message_editor.read(cx).text(cx).len();
let separator = "\n\n";
cursor_offset
} else {
let existing_len = message_editor.read(cx).text(cx).len();
let separator = "\n\n";
message_editor.update(cx, |editor, cx| {
editor.append_message(queued_content, Some(separator), window, cx);
});
cursor_offset.map(|offset| existing_len + separator.len() + offset)
};
message_editor.update(cx, |editor, cx| {
editor.append_message(queued_content, Some(separator), window, cx);
if let Some(offset) = cursor_offset {
let adjusted_offset = existing_len + separator.len() + offset;
editor.set_cursor_offset(adjusted_offset, window, cx);
if let Some(offset) = adjusted_cursor_offset {
editor.set_cursor_offset(offset, window, cx);
}
if let Some(inserted_text) = inserted_text.as_deref() {
editor.insert_text(inserted_text, window, cx);
match attempt {
Some(InputAttempt::Text(text)) => {
editor.insert_text(&text, window, cx);
}
Some(InputAttempt::Paste(clipboard)) => {
editor.paste_item(&clipboard, window, cx);
}
None => {}
}
});

View file

@ -145,6 +145,12 @@ pub struct MessageEditor {
_parse_slash_command_task: Task<()>,
}
#[derive(Clone, Debug)]
pub enum InputAttempt {
Text(Arc<str>),
Paste(ClipboardItem),
}
#[derive(Clone, Debug)]
pub enum MessageEditorEvent {
Send,
@ -153,7 +159,7 @@ pub enum MessageEditorEvent {
Focus,
LostFocus,
InputAttempted {
text: Arc<str>,
attempt: InputAttempt,
cursor_offset: usize,
},
}
@ -494,7 +500,7 @@ impl MessageEditor {
.to_offset(&editor.buffer().read(cx).snapshot(cx))
.0;
cx.emit(MessageEditorEvent::InputAttempted {
text: text.clone(),
attempt: InputAttempt::Text(text.clone()),
cursor_offset,
});
}
@ -954,18 +960,47 @@ impl MessageEditor {
cx.emit(MessageEditorEvent::Cancel)
}
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
pub fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
let Some(clipboard) = cx.read_from_clipboard() else {
return;
};
if self.editor.read(cx).read_only(cx) {
let editor = self.editor.read(cx);
let cursor_offset = editor
.selections
.newest_anchor()
.head()
.to_offset(&editor.buffer().read(cx).snapshot(cx))
.0;
cx.emit(MessageEditorEvent::InputAttempted {
attempt: InputAttempt::Paste(clipboard),
cursor_offset,
});
cx.stop_propagation();
return;
}
cx.stop_propagation();
self.paste_item(&clipboard, window, cx);
}
pub fn paste_item(
&mut self,
clipboard: &ClipboardItem,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let editor_clipboard_selections = cx.read_from_clipboard().and_then(|item| {
item.entries().iter().find_map(|entry| match entry {
let editor_clipboard_selections =
clipboard.entries().iter().find_map(|entry| match entry {
ClipboardEntry::String(text) => {
text.metadata_json::<Vec<editor::ClipboardSelection>>()
}
_ => None,
})
});
});
// Insert creases for pasted clipboard selections that:
// 1. Contain exactly one selection
@ -997,7 +1032,6 @@ impl MessageEditor {
.unwrap_or(false);
if should_insert_creases && let Some(selections) = editor_clipboard_selections {
cx.stop_propagation();
let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
let (insertion_target, _) = snapshot
.anchor_to_buffer_anchor(self.editor.read(cx).selections.newest_anchor().start)
@ -1085,14 +1119,12 @@ impl MessageEditor {
}
// Handle text paste with potential markdown mention links before
// clipboard context entries so markdown text still pastes as text.
if let Some(clipboard_text) = cx.read_from_clipboard().and_then(|item| {
item.entries().iter().find_map(|entry| match entry {
ClipboardEntry::String(text) => Some(text.text().to_string()),
_ => None,
})
}) {
let clipboard_text = clipboard.entries().iter().find_map(|entry| match entry {
ClipboardEntry::String(text) => Some(text.text().to_string()),
_ => None,
});
if let Some(clipboard_text) = clipboard_text.as_deref() {
if clipboard_text.contains("[@") {
cx.stop_propagation();
let selections_before = self.editor.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor
@ -1109,7 +1141,7 @@ impl MessageEditor {
});
self.editor.update(cx, |editor, cx| {
editor.insert(&clipboard_text, window, cx);
editor.insert(clipboard_text, window, cx);
});
let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
@ -1180,12 +1212,13 @@ impl MessageEditor {
}
}
if self.handle_pasted_context(window, cx) {
if self.handle_pasted_context(clipboard, window, cx) {
return;
}
// Fall through to default editor paste
cx.propagate();
self.editor.update(cx, |editor, cx| {
editor.paste_item(clipboard, window, cx);
});
}
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
@ -1205,11 +1238,12 @@ impl MessageEditor {
});
}
fn handle_pasted_context(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(clipboard) = cx.read_from_clipboard() else {
return false;
};
fn handle_pasted_context(
&mut self,
clipboard: &ClipboardItem,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if matches!(
clipboard.entries().first(),
Some(ClipboardEntry::String(_)) | None
@ -1229,9 +1263,7 @@ impl MessageEditor {
let editor = self.editor.clone();
let mention_set = self.mention_set.clone();
let workspace = self.workspace.clone();
let entries = clipboard.into_entries().collect::<Vec<_>>();
cx.stop_propagation();
let entries = clipboard.clone().into_entries().collect::<Vec<_>>();
window
.spawn(cx, async move |mut cx| {

View file

@ -14756,24 +14756,33 @@ impl Editor {
}
pub fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
if let Some(item) = cx.read_from_clipboard() {
self.paste_item(&item, window, cx);
}
}
pub fn paste_item(
&mut self,
item: &ClipboardItem,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.read_only(cx) {
return;
}
if let Some(item) = cx.read_from_clipboard() {
let clipboard_string = item.entries().iter().find_map(|entry| match entry {
ClipboardEntry::String(s) => Some(s),
_ => None,
});
match clipboard_string {
Some(clipboard_string) => self.do_paste(
clipboard_string.text(),
clipboard_string.metadata_json::<Vec<ClipboardSelection>>(),
true,
window,
cx,
),
_ => self.do_paste(&item.text().unwrap_or_default(), None, true, window, cx),
}
let clipboard_string = item.entries().iter().find_map(|entry| match entry {
ClipboardEntry::String(s) => Some(s),
_ => None,
});
match clipboard_string {
Some(clipboard_string) => self.do_paste(
clipboard_string.text(),
clipboard_string.metadata_json::<Vec<ClipboardSelection>>(),
true,
window,
cx,
),
_ => self.do_paste(&item.text().unwrap_or_default(), None, true, window, cx),
}
}