acp thread view: Floating editing message controls (#36283)

Agus Zubiaga and Danilo Leal created

Prevents layout shift when focusing the editor

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

crates/agent_ui/src/acp/entry_view_state.rs |   1 
crates/agent_ui/src/acp/message_editor.rs   |   5 
crates/agent_ui/src/acp/thread_view.rs      | 235 +++++++++-------------
3 files changed, 105 insertions(+), 136 deletions(-)

Detailed changes

crates/agent_ui/src/acp/entry_view_state.rs 🔗

@@ -67,6 +67,7 @@ impl EntryViewState {
                         self.project.clone(),
                         self.thread_store.clone(),
                         self.text_thread_store.clone(),
+                        "Edit message - @ to include context",
                         editor::EditorMode::AutoHeight {
                             min_lines: 1,
                             max_lines: None,

crates/agent_ui/src/acp/message_editor.rs 🔗

@@ -71,6 +71,7 @@ impl MessageEditor {
         project: Entity<Project>,
         thread_store: Entity<ThreadStore>,
         text_thread_store: Entity<TextThreadStore>,
+        placeholder: impl Into<Arc<str>>,
         mode: EditorMode,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -94,7 +95,7 @@ impl MessageEditor {
             let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
 
             let mut editor = Editor::new(mode, buffer, None, window, cx);
-            editor.set_placeholder_text("Message the agent - @ to include files", cx);
+            editor.set_placeholder_text(placeholder, cx);
             editor.set_show_indent_guides(false, cx);
             editor.set_soft_wrap();
             editor.set_use_modal_editing(true);
@@ -1276,6 +1277,7 @@ mod tests {
                     project.clone(),
                     thread_store.clone(),
                     text_thread_store.clone(),
+                    "Test",
                     EditorMode::AutoHeight {
                         min_lines: 1,
                         max_lines: None,
@@ -1473,6 +1475,7 @@ mod tests {
                     project.clone(),
                     thread_store.clone(),
                     text_thread_store.clone(),
+                    "Test",
                     EditorMode::AutoHeight {
                         max_lines: None,
                         min_lines: 1,

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -159,6 +159,7 @@ impl AcpThreadView {
                 project.clone(),
                 thread_store.clone(),
                 text_thread_store.clone(),
+                "Message the agent - @ to include context",
                 editor::EditorMode::AutoHeight {
                     min_lines: MIN_EDITOR_LINES,
                     max_lines: Some(MAX_EDITOR_LINES),
@@ -426,7 +427,9 @@ impl AcpThreadView {
         match event {
             MessageEditorEvent::Send => self.send(window, cx),
             MessageEditorEvent::Cancel => self.cancel_generation(cx),
-            MessageEditorEvent::Focus => {}
+            MessageEditorEvent::Focus => {
+                self.cancel_editing(&Default::default(), window, cx);
+            }
         }
     }
 
@@ -742,44 +745,98 @@ impl AcpThreadView {
         cx: &Context<Self>,
     ) -> AnyElement {
         let primary = match &entry {
-            AgentThreadEntry::UserMessage(message) => div()
-                .id(("user_message", entry_ix))
-                .py_4()
-                .px_2()
-                .children(message.id.clone().and_then(|message_id| {
-                    message.checkpoint.as_ref()?.show.then(|| {
-                        Button::new("restore-checkpoint", "Restore Checkpoint")
-                            .icon(IconName::Undo)
-                            .icon_size(IconSize::XSmall)
-                            .icon_position(IconPosition::Start)
-                            .label_size(LabelSize::XSmall)
-                            .on_click(cx.listener(move |this, _, _window, cx| {
-                                this.rewind(&message_id, cx);
-                            }))
-                    })
-                }))
-                .child(
-                    v_flex()
-                        .p_3()
-                        .gap_1p5()
-                        .rounded_lg()
-                        .shadow_md()
-                        .bg(cx.theme().colors().editor_background)
-                        .border_1()
-                        .border_color(cx.theme().colors().border)
-                        .text_xs()
-                        .children(
-                            self.entry_view_state
-                                .read(cx)
-                                .entry(entry_ix)
-                                .and_then(|entry| entry.message_editor())
-                                .map(|editor| {
-                                    self.render_sent_message_editor(entry_ix, editor, cx)
-                                        .into_any_element()
-                                }),
-                        ),
-                )
-                .into_any(),
+            AgentThreadEntry::UserMessage(message) => {
+                let Some(editor) = self
+                    .entry_view_state
+                    .read(cx)
+                    .entry(entry_ix)
+                    .and_then(|entry| entry.message_editor())
+                    .cloned()
+                else {
+                    return Empty.into_any_element();
+                };
+
+                let editing = self.editing_message == Some(entry_ix);
+                let editor_focus = editor.focus_handle(cx).is_focused(window);
+                let focus_border = cx.theme().colors().border_focused;
+
+                div()
+                    .id(("user_message", entry_ix))
+                    .py_4()
+                    .px_2()
+                    .children(message.id.clone().and_then(|message_id| {
+                        message.checkpoint.as_ref()?.show.then(|| {
+                            Button::new("restore-checkpoint", "Restore Checkpoint")
+                                .icon(IconName::Undo)
+                                .icon_size(IconSize::XSmall)
+                                .icon_position(IconPosition::Start)
+                                .label_size(LabelSize::XSmall)
+                                .on_click(cx.listener(move |this, _, _window, cx| {
+                                    this.rewind(&message_id, cx);
+                                }))
+                        })
+                    }))
+                    .child(
+                        div()
+                            .relative()
+                            .child(
+                                div()
+                                    .p_3()
+                                    .rounded_lg()
+                                    .shadow_md()
+                                    .bg(cx.theme().colors().editor_background)
+                                    .border_1()
+                                    .when(editing && !editor_focus, |this| this.border_dashed())
+                                    .border_color(cx.theme().colors().border)
+                                    .map(|this|{
+                                        if editor_focus {
+                                            this.border_color(focus_border)
+                                        } else {
+                                            this.hover(|s| s.border_color(focus_border.opacity(0.8)))
+                                        }
+                                    })
+                                    .text_xs()
+                                    .child(editor.clone().into_any_element()),
+                            )
+                            .when(editor_focus, |this|
+                                this.child(
+                                    h_flex()
+                                        .absolute()
+                                        .top_neg_3p5()
+                                        .right_3()
+                                        .gap_1()
+                                        .rounded_sm()
+                                        .border_1()
+                                        .border_color(cx.theme().colors().border)
+                                        .bg(cx.theme().colors().editor_background)
+                                        .overflow_hidden()
+                                        .child(
+                                            IconButton::new("cancel", IconName::Close)
+                                                .icon_color(Color::Error)
+                                                .icon_size(IconSize::XSmall)
+                                                .on_click(cx.listener(Self::cancel_editing))
+                                        )
+                                        .child(
+                                            IconButton::new("regenerate", IconName::Return)
+                                                .icon_color(Color::Muted)
+                                                .icon_size(IconSize::XSmall)
+                                                .tooltip(Tooltip::text(
+                                                    "Editing will restart the thread from this point."
+                                                ))
+                                                .on_click(cx.listener({
+                                                    let editor = editor.clone();
+                                                    move |this, _, window, cx| {
+                                                        this.regenerate(
+                                                            entry_ix, &editor, window, cx,
+                                                        );
+                                                    }
+                                                })),
+                                        )
+                                )
+                            ),
+                    )
+                    .into_any()
+            }
             AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
                 let style = default_markdown_style(false, window, cx);
                 let message_body = v_flex()
@@ -854,20 +911,12 @@ impl AcpThreadView {
         if let Some(editing_index) = self.editing_message.as_ref()
             && *editing_index < entry_ix
         {
-            let backdrop = div()
-                .id(("backdrop", entry_ix))
-                .size_full()
-                .absolute()
-                .inset_0()
-                .bg(cx.theme().colors().panel_background)
-                .opacity(0.8)
-                .block_mouse_except_scroll()
-                .on_click(cx.listener(Self::cancel_editing));
-
             div()
-                .relative()
                 .child(primary)
-                .child(backdrop)
+                .opacity(0.2)
+                .block_mouse_except_scroll()
+                .id("overlay")
+                .on_click(cx.listener(Self::cancel_editing))
                 .into_any_element()
         } else {
             primary
@@ -2512,90 +2561,6 @@ impl AcpThreadView {
         )
     }
 
-    fn render_sent_message_editor(
-        &self,
-        entry_ix: usize,
-        editor: &Entity<MessageEditor>,
-        cx: &Context<Self>,
-    ) -> Div {
-        v_flex().w_full().gap_2().child(editor.clone()).when(
-            self.editing_message == Some(entry_ix),
-            |el| {
-                el.child(
-                    h_flex()
-                        .gap_1()
-                        .child(
-                            Icon::new(IconName::Warning)
-                                .color(Color::Warning)
-                                .size(IconSize::XSmall),
-                        )
-                        .child(
-                            Label::new("Editing will restart the thread from this point.")
-                                .color(Color::Muted)
-                                .size(LabelSize::XSmall),
-                        )
-                        .child(self.render_sent_message_editor_buttons(entry_ix, editor, cx)),
-                )
-            },
-        )
-    }
-
-    fn render_sent_message_editor_buttons(
-        &self,
-        entry_ix: usize,
-        editor: &Entity<MessageEditor>,
-        cx: &Context<Self>,
-    ) -> Div {
-        h_flex()
-            .gap_0p5()
-            .flex_1()
-            .justify_end()
-            .child(
-                IconButton::new("cancel-edit-message", IconName::Close)
-                    .shape(ui::IconButtonShape::Square)
-                    .icon_color(Color::Error)
-                    .icon_size(IconSize::Small)
-                    .tooltip({
-                        let focus_handle = editor.focus_handle(cx);
-                        move |window, cx| {
-                            Tooltip::for_action_in(
-                                "Cancel Edit",
-                                &menu::Cancel,
-                                &focus_handle,
-                                window,
-                                cx,
-                            )
-                        }
-                    })
-                    .on_click(cx.listener(Self::cancel_editing)),
-            )
-            .child(
-                IconButton::new("confirm-edit-message", IconName::Return)
-                    .disabled(editor.read(cx).is_empty(cx))
-                    .shape(ui::IconButtonShape::Square)
-                    .icon_color(Color::Muted)
-                    .icon_size(IconSize::Small)
-                    .tooltip({
-                        let focus_handle = editor.focus_handle(cx);
-                        move |window, cx| {
-                            Tooltip::for_action_in(
-                                "Regenerate",
-                                &menu::Confirm,
-                                &focus_handle,
-                                window,
-                                cx,
-                            )
-                        }
-                    })
-                    .on_click(cx.listener({
-                        let editor = editor.clone();
-                        move |this, _, window, cx| {
-                            this.regenerate(entry_ix, &editor, window, cx);
-                        }
-                    })),
-            )
-    }
-
     fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
         if self.thread().map_or(true, |thread| {
             thread.read(cx).status() == ThreadStatus::Idle