diff --git a/assets/icons/queue_message.svg b/assets/icons/queue_message.svg new file mode 100644 index 0000000000000000000000000000000000000000..1bdf6738bcf3143fc13a820281cf1cab8531bd36 --- /dev/null +++ b/assets/icons/queue_message.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index a3a62459a2e98680b3910877cc9cd1e6e58ba056..93bf7c98098530b23522c60f987f9e341ebc69ca 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -1165,6 +1165,20 @@ impl ConnectionView { } } + fn move_queued_message_to_main_editor( + &mut self, + index: usize, + inserted_text: Option<&str>, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(active) = self.active_thread() { + active.update(cx, |active, cx| { + active.move_queued_message_to_main_editor(index, inserted_text, window, cx); + }); + } + } + fn handle_thread_event( &mut self, thread: &Entity, @@ -2162,6 +2176,7 @@ impl ConnectionView { for (index, editor) in editors.into_iter().enumerate() { if let Some(content) = queued_messages.get(index) { editor.update(cx, |editor, cx| { + editor.set_read_only(true, cx); editor.set_message(content.clone(), window, cx); }); } @@ -2190,6 +2205,7 @@ impl ConnectionView { window, cx, ); + editor.set_read_only(true, cx); editor.set_message(content, window, cx); editor }); @@ -2198,6 +2214,8 @@ impl ConnectionView { &editor, window, move |this, _editor, event, window, cx| match event { + MessageEditorEvent::InputAttempted(text) => this + .move_queued_message_to_main_editor(index, Some(text.as_ref()), window, cx), MessageEditorEvent::LostFocus => { this.save_queued_message_at_index(index, cx); } @@ -6084,4 +6102,86 @@ pub(crate) mod tests { assert_eq!(tool_call_id, acp::ToolCallId::new("tc-b")); }); } + + #[gpui::test] + async fn test_move_queued_message_to_empty_main_editor(cx: &mut TestAppContext) { + init_test(cx); + + let (connection_view, cx) = + setup_thread_view(StubAgentServer::default_response(), cx).await; + + // Add a plain-text message to the queue directly. + active_thread(&connection_view, cx).update_in(cx, |thread, window, cx| { + thread.add_to_queue( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "queued message".to_string(), + ))], + vec![], + cx, + ); + // Main editor must be empty for this path — it is by default, but + // assert to make the precondition explicit. + assert!(thread.message_editor.read(cx).is_empty(cx)); + thread.move_queued_message_to_main_editor(0, None, window, cx); + }); + + cx.run_until_parked(); + + // Queue should now be empty. + let queue_len = active_thread(&connection_view, cx) + .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); + assert_eq!(queue_len, 0, "Queue should be empty after move"); + + // Main editor should contain the queued message text. + let text = message_editor(&connection_view, cx).update(cx, |editor, cx| editor.text(cx)); + assert_eq!( + text, "queued message", + "Main editor should contain the moved queued message" + ); + } + + #[gpui::test] + async fn test_move_queued_message_to_non_empty_main_editor(cx: &mut TestAppContext) { + init_test(cx); + + let (connection_view, cx) = + setup_thread_view(StubAgentServer::default_response(), cx).await; + + // Seed the main editor with existing content. + message_editor(&connection_view, cx).update_in(cx, |editor, window, cx| { + editor.set_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "existing content".to_string(), + ))], + window, + cx, + ); + }); + + // Add a plain-text message to the queue. + active_thread(&connection_view, cx).update_in(cx, |thread, window, cx| { + thread.add_to_queue( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "queued message".to_string(), + ))], + vec![], + cx, + ); + thread.move_queued_message_to_main_editor(0, None, window, cx); + }); + + cx.run_until_parked(); + + // Queue should now be empty. + let queue_len = active_thread(&connection_view, cx) + .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); + assert_eq!(queue_len, 0, "Queue should be empty after move"); + + // Main editor should contain existing content + separator + queued content. + let text = message_editor(&connection_view, cx).update(cx, |editor, cx| editor.text(cx)); + assert_eq!( + text, "existing content\n\nqueued message", + "Main editor should have existing content and queued message separated by two newlines" + ); + } } diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 0915911104b50ffe9d464a0552d3e43b0794b34d..b8403f8052e32fbeeceb4594438eecf32aa4e2e7 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -463,6 +463,7 @@ impl ThreadView { self.cancel_editing(&Default::default(), window, cx); } MessageEditorEvent::LostFocus => {} + MessageEditorEvent::InputAttempted(_) => {} } } @@ -577,6 +578,7 @@ impl ThreadView { ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => { self.cancel_editing(&Default::default(), window, cx); } + ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted(_)) => {} ViewEvent::OpenDiffLocation { path, position, @@ -1217,6 +1219,44 @@ impl ThreadView { self.send_content(contents_task, window, cx); } + pub fn move_queued_message_to_main_editor( + &mut self, + index: usize, + inserted_text: Option<&str>, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(queued_message) = self.remove_from_queue(index, cx) else { + return false; + }; + 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) { + message_editor.update(cx, |editor, cx| { + editor.set_message(queued_content, window, cx); + if let Some(inserted_text) = inserted_text.as_deref() { + editor.insert_text(inserted_text, window, cx); + } + }); + cx.notify(); + return true; + } + + message_editor.update(cx, |editor, cx| { + editor.append_message(queued_content, Some("\n\n"), window, cx); + if let Some(inserted_text) = inserted_text.as_deref() { + editor.insert_text(inserted_text, window, cx); + } + }); + + cx.notify(); + true + } + // editor methods pub fn expand_message_editor( @@ -2663,50 +2703,24 @@ impl ThreadView { .child(if editor_focused { h_flex() .gap_1() - .min_w_40() + .min_w(rems_from_px(150.)) + .justify_end() .child( - IconButton::new(("cancel_edit", index), IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |_window, cx| { - Tooltip::for_action_in( - "Cancel Edit", - &editor::actions::Cancel, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let main_editor = self.message_editor.clone(); - cx.listener(move |_, _, window, cx| { - window.focus(&main_editor.focus_handle(cx), cx); - }) - }), - ) - .child( - IconButton::new(("save_edit", index), IconName::Check) + IconButton::new(("edit", index), IconName::Pencil) .icon_size(IconSize::Small) - .icon_color(Color::Success) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |_window, cx| { - Tooltip::for_action_in( - "Save Edit", - &Chat, - &focus_handle, - cx, - ) - } + .tooltip(|_window, cx| { + Tooltip::with_meta( + "Edit Queued Message", + None, + "Type anything to edit", + cx, + ) }) - .on_click({ - let main_editor = self.message_editor.clone(); - cx.listener(move |_, _, window, cx| { - window.focus(&main_editor.focus_handle(cx), cx); - }) - }), + .on_click(cx.listener(move |this, _, window, cx| { + this.move_queued_message_to_main_editor( + index, None, window, cx, + ); + })), ) .child( Button::new(("send_now_focused", index), "Send Now") @@ -2728,62 +2742,64 @@ impl ThreadView { ) } else { h_flex() - .gap_1() .when(!is_next, |this| this.visible_on_hover("queue_entry")) + .gap_1() + .min_w(rems_from_px(150.)) + .justify_end() .child( - IconButton::new(("edit", index), IconName::Pencil) + IconButton::new(("delete", index), IconName::Trash) .icon_size(IconSize::Small) .tooltip({ let focus_handle = focus_handle.clone(); move |_window, cx| { if is_next { Tooltip::for_action_in( - "Edit", - &EditFirstQueuedMessage, + "Remove Message from Queue", + &RemoveFirstQueuedMessage, &focus_handle, cx, ) } else { - Tooltip::simple("Edit", cx) + Tooltip::simple( + "Remove Message from Queue", + cx, + ) } } }) - .on_click({ - let editor = editor.clone(); - cx.listener(move |_, _, window, cx| { - window.focus(&editor.focus_handle(cx), cx); - }) - }), + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_from_queue(index, cx); + cx.notify(); + })), ) .child( - IconButton::new(("delete", index), IconName::Trash) + IconButton::new(("edit", index), IconName::Pencil) .icon_size(IconSize::Small) .tooltip({ let focus_handle = focus_handle.clone(); move |_window, cx| { if is_next { Tooltip::for_action_in( - "Remove Message from Queue", - &RemoveFirstQueuedMessage, + "Edit", + &EditFirstQueuedMessage, &focus_handle, cx, ) } else { - Tooltip::simple( - "Remove Message from Queue", - cx, - ) + Tooltip::simple("Edit", cx) } } }) - .on_click(cx.listener(move |this, _, _, cx| { - this.remove_from_queue(index, cx); - cx.notify(); + .on_click(cx.listener(move |this, _, window, cx| { + this.move_queued_message_to_main_editor( + index, None, window, cx, + ); })), ) .child( Button::new(("send_now", index), "Send Now") .label_size(LabelSize::Small) + .when(is_next, |this| this.style(ButtonStyle::Outlined)) .when(is_next && message_editor.is_empty(cx), |this| { let action: Box = if can_fast_track { @@ -2792,7 +2808,7 @@ impl ThreadView { Box::new(SendNextQueuedMessage) }; - this.style(ButtonStyle::Outlined).key_binding( + this.key_binding( KeyBinding::for_action_in( action.as_ref(), &focus_handle.clone(), @@ -2801,9 +2817,6 @@ impl ThreadView { .map(|kb| kb.size(keybinding_size)), ) }) - .when(is_next && !message_editor.is_empty(cx), |this| { - this.style(ButtonStyle::Outlined) - }) .on_click(cx.listener(move |this, _, window, cx| { this.send_queued_message_at_index( index, true, window, cx, @@ -3281,7 +3294,12 @@ impl ThreadView { .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx))) .into_any_element() } else { - IconButton::new("send-message", IconName::Send) + let send_icon = if is_generating { + IconName::QueueMessage + } else { + IconName::Send + }; + IconButton::new("send-message", send_icon) .style(ButtonStyle::Filled) .map(|this| { if is_editor_empty && !is_generating { @@ -7577,9 +7595,7 @@ impl Render for ThreadView { cx.notify(); })) .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| { - if let Some(editor) = this.queued_message_editors.first() { - window.focus(&editor.focus_handle(cx), cx); - } + this.move_queued_message_to_main_editor(0, None, window, cx); })) .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| { this.local_queued_messages.clear(); diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index 071142f083bc94b5d057a366d124d11e7822d1fd..aef7f1f335eff7d092f924b9883ab0d64bbf65a8 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -114,7 +114,7 @@ impl EntryViewState { cx.subscribe(&message_editor, move |_, editor, event, cx| { cx.emit(EntryViewEvent { entry_index: index, - view_event: ViewEvent::MessageEditorEvent(editor, *event), + view_event: ViewEvent::MessageEditorEvent(editor, event.clone()), }) }) .detach(); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index a24a5f5f65dae3f8bbce7d0a7b7f4988a1bd5e38..274b076eafbcfab4620c66c027c374025242f821 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -51,13 +51,14 @@ pub struct MessageEditor { _parse_slash_command_task: Task<()>, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub enum MessageEditorEvent { Send, SendImmediately, Cancel, Focus, LostFocus, + InputAttempted(Arc), } impl EventEmitter for MessageEditor {} @@ -186,6 +187,18 @@ impl MessageEditor { subscriptions.push(cx.subscribe_in(&editor, window, { move |this, editor, event, window, cx| { + let input_attempted_text = match event { + EditorEvent::InputHandled { text, .. } => Some(text), + EditorEvent::InputIgnored { text } => Some(text), + _ => None, + }; + if let Some(text) = input_attempted_text + && editor.read(cx).read_only(cx) + && !text.is_empty() + { + cx.emit(MessageEditorEvent::InputAttempted(text.clone())); + } + if let EditorEvent::Edited { .. } = event && !editor.read(cx).read_only(cx) { @@ -1195,13 +1208,45 @@ impl MessageEditor { message: Vec, window: &mut Window, cx: &mut Context, + ) { + self.clear(window, cx); + self.insert_message_blocks(message, false, window, cx); + } + + pub fn append_message( + &mut self, + message: Vec, + separator: Option<&str>, + window: &mut Window, + cx: &mut Context, + ) { + if message.is_empty() { + return; + } + + if let Some(separator) = separator + && !separator.is_empty() + && !self.is_empty(cx) + { + self.editor.update(cx, |editor, cx| { + editor.insert(separator, window, cx); + }); + } + + self.insert_message_blocks(message, true, window, cx); + } + + fn insert_message_blocks( + &mut self, + message: Vec, + append_to_existing: bool, + window: &mut Window, + cx: &mut Context, ) { let Some(workspace) = self.workspace.upgrade() else { return; }; - self.clear(window, cx); - let path_style = workspace.read(cx).project().read(cx).path_style(cx); let mut text = String::new(); let mut mentions = Vec::new(); @@ -1275,13 +1320,31 @@ impl MessageEditor { } } - let snapshot = self.editor.update(cx, |editor, cx| { - editor.set_text(text, window, cx); - editor.buffer().read(cx).snapshot(cx) - }); + if text.is_empty() && mentions.is_empty() { + return; + } + + let insertion_start = if append_to_existing { + self.editor.read(cx).text(cx).len() + } else { + 0 + }; + + let snapshot = if append_to_existing { + self.editor.update(cx, |editor, cx| { + editor.insert(&text, window, cx); + editor.buffer().read(cx).snapshot(cx) + }) + } else { + self.editor.update(cx, |editor, cx| { + editor.set_text(text, window, cx); + editor.buffer().read(cx).snapshot(cx) + }) + }; for (range, mention_uri, mention) in mentions { - let anchor = snapshot.anchor_before(MultiBufferOffset(range.start)); + let adjusted_start = insertion_start + range.start; + let anchor = snapshot.anchor_before(MultiBufferOffset(adjusted_start)); let Some((crease_id, tx)) = insert_crease_for_mention( anchor.excerpt_id, anchor.text_anchor, @@ -1306,6 +1369,7 @@ impl MessageEditor { ) }); } + cx.notify(); } @@ -1313,6 +1377,16 @@ impl MessageEditor { self.editor.read(cx).text(cx) } + pub fn insert_text(&mut self, text: &str, window: &mut Window, cx: &mut Context) { + if text.is_empty() { + return; + } + + self.editor.update(cx, |editor, cx| { + editor.insert(text, window, cx); + }); + } + pub fn set_placeholder_text( &mut self, placeholder: &str, @@ -3461,4 +3535,241 @@ mod tests { text ); } + + // 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( + cx: &mut TestAppContext, + ) -> (Entity, &mut VisualTestContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({"file.txt": ""})).await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let history = + cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + MessageEditor::new( + workspace.downgrade(), + project.downgrade(), + None, + history.downgrade(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ) + }) + }); + + cx.run_until_parked(); + (message_editor, cx) + } + + #[gpui::test] + async fn test_set_message_plain_text(cx: &mut TestAppContext) { + init_test(cx); + let (message_editor, cx) = setup_message_editor(cx).await; + + message_editor.update_in(cx, |editor, window, cx| { + editor.set_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "hello world".to_string(), + ))], + window, + cx, + ); + }); + + let text = message_editor.update(cx, |editor, cx| editor.text(cx)); + assert_eq!(text, "hello world"); + assert!(!message_editor.update(cx, |editor, cx| editor.is_empty(cx))); + } + + #[gpui::test] + async fn test_set_message_replaces_existing_content(cx: &mut TestAppContext) { + init_test(cx); + let (message_editor, cx) = setup_message_editor(cx).await; + + // Set initial content. + message_editor.update_in(cx, |editor, window, cx| { + editor.set_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "old content".to_string(), + ))], + window, + cx, + ); + }); + + // Replace with new content. + message_editor.update_in(cx, |editor, window, cx| { + editor.set_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "new content".to_string(), + ))], + window, + cx, + ); + }); + + let text = message_editor.update(cx, |editor, cx| editor.text(cx)); + assert_eq!( + text, "new content", + "set_message should replace old content" + ); + } + + #[gpui::test] + async fn test_append_message_to_empty_editor(cx: &mut TestAppContext) { + init_test(cx); + let (message_editor, cx) = setup_message_editor(cx).await; + + message_editor.update_in(cx, |editor, window, cx| { + editor.append_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "appended".to_string(), + ))], + Some("\n\n"), + window, + cx, + ); + }); + + let text = message_editor.update(cx, |editor, cx| editor.text(cx)); + assert_eq!( + text, "appended", + "No separator should be inserted when the editor is empty" + ); + } + + #[gpui::test] + async fn test_append_message_to_non_empty_editor(cx: &mut TestAppContext) { + init_test(cx); + let (message_editor, cx) = setup_message_editor(cx).await; + + // Seed initial content. + message_editor.update_in(cx, |editor, window, cx| { + editor.set_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "initial".to_string(), + ))], + window, + cx, + ); + }); + + // Append with separator. + message_editor.update_in(cx, |editor, window, cx| { + editor.append_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "appended".to_string(), + ))], + Some("\n\n"), + window, + cx, + ); + }); + + let text = message_editor.update(cx, |editor, cx| editor.text(cx)); + assert_eq!( + text, "initial\n\nappended", + "Separator should appear between existing and appended content" + ); + } + + #[gpui::test] + async fn test_append_message_preserves_mention_offset(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({"file.txt": "content"})) + .await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let history = + cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + MessageEditor::new( + workspace.downgrade(), + project.downgrade(), + None, + history.downgrade(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ) + }) + }); + + cx.run_until_parked(); + + // Seed plain-text prefix so the editor is non-empty before appending. + message_editor.update_in(cx, |editor, window, cx| { + editor.set_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "prefix text".to_string(), + ))], + window, + cx, + ); + }); + + // Append a message that contains a ResourceLink mention. + message_editor.update_in(cx, |editor, window, cx| { + editor.append_message( + vec![acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + "file.txt", + "file:///project/file.txt", + ))], + Some("\n\n"), + window, + cx, + ); + }); + + cx.run_until_parked(); + + // The mention should be registered in the mention_set so that contents() + // will emit it as a structured block rather than plain text. + let mention_uris = + message_editor.update(cx, |editor, cx| editor.mention_set.read(cx).mentions()); + assert_eq!( + mention_uris.len(), + 1, + "Expected exactly one mention in the mention_set after append, got: {mention_uris:?}" + ); + + // The editor text should start with the prefix, then the separator, then + // the mention placeholder — confirming the offset was computed correctly. + let text = message_editor.update(cx, |editor, cx| editor.text(cx)); + assert!( + text.starts_with("prefix text\n\n"), + "Expected text to start with 'prefix text\\n\\n', got: {text:?}" + ); + } } diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index a8a4e47cd0046fa995b10bb5e91b8884d70cdd6d..73db39afdc5e9bd15f084043370d27f0494569a6 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -191,6 +191,7 @@ pub enum IconName { Power, Public, PullRequest, + QueueMessage, Quote, Reader, RefreshTitle,