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

Prevents layout shift when focusing the editor

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
Agus Zubiaga 2025-08-18 09:50:29 -03:00 committed by GitHub
parent 58f7006898
commit e2db434920
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 105 additions and 136 deletions

View file

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

View file

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

View file

@ -159,6 +159,7 @@ impl AcpThreadView {
project.clone(), project.clone(),
thread_store.clone(), thread_store.clone(),
text_thread_store.clone(), text_thread_store.clone(),
"Message the agent @ to include context",
editor::EditorMode::AutoHeight { editor::EditorMode::AutoHeight {
min_lines: MIN_EDITOR_LINES, min_lines: MIN_EDITOR_LINES,
max_lines: Some(MAX_EDITOR_LINES), max_lines: Some(MAX_EDITOR_LINES),
@ -426,7 +427,9 @@ impl AcpThreadView {
match event { match event {
MessageEditorEvent::Send => self.send(window, cx), MessageEditorEvent::Send => self.send(window, cx),
MessageEditorEvent::Cancel => self.cancel_generation(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>, cx: &Context<Self>,
) -> AnyElement { ) -> AnyElement {
let primary = match &entry { let primary = match &entry {
AgentThreadEntry::UserMessage(message) => div() AgentThreadEntry::UserMessage(message) => {
.id(("user_message", entry_ix)) let Some(editor) = self
.py_4() .entry_view_state
.px_2() .read(cx)
.children(message.id.clone().and_then(|message_id| { .entry(entry_ix)
message.checkpoint.as_ref()?.show.then(|| { .and_then(|entry| entry.message_editor())
Button::new("restore-checkpoint", "Restore Checkpoint") .cloned()
.icon(IconName::Undo) else {
.icon_size(IconSize::XSmall) return Empty.into_any_element();
.icon_position(IconPosition::Start) };
.label_size(LabelSize::XSmall)
.on_click(cx.listener(move |this, _, _window, cx| { let editing = self.editing_message == Some(entry_ix);
this.rewind(&message_id, cx); let editor_focus = editor.focus_handle(cx).is_focused(window);
})) let focus_border = cx.theme().colors().border_focused;
})
})) div()
.child( .id(("user_message", entry_ix))
v_flex() .py_4()
.p_3() .px_2()
.gap_1p5() .children(message.id.clone().and_then(|message_id| {
.rounded_lg() message.checkpoint.as_ref()?.show.then(|| {
.shadow_md() Button::new("restore-checkpoint", "Restore Checkpoint")
.bg(cx.theme().colors().editor_background) .icon(IconName::Undo)
.border_1() .icon_size(IconSize::XSmall)
.border_color(cx.theme().colors().border) .icon_position(IconPosition::Start)
.text_xs() .label_size(LabelSize::XSmall)
.children( .on_click(cx.listener(move |this, _, _window, cx| {
self.entry_view_state this.rewind(&message_id, cx);
.read(cx) }))
.entry(entry_ix) })
.and_then(|entry| entry.message_editor()) }))
.map(|editor| { .child(
self.render_sent_message_editor(entry_ix, editor, cx) div()
.into_any_element() .relative()
}), .child(
), div()
) .p_3()
.into_any(), .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 }) => { AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
let style = default_markdown_style(false, window, cx); let style = default_markdown_style(false, window, cx);
let message_body = v_flex() let message_body = v_flex()
@ -854,20 +911,12 @@ impl AcpThreadView {
if let Some(editing_index) = self.editing_message.as_ref() if let Some(editing_index) = self.editing_message.as_ref()
&& *editing_index < entry_ix && *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() div()
.relative()
.child(primary) .child(primary)
.child(backdrop) .opacity(0.2)
.block_mouse_except_scroll()
.id("overlay")
.on_click(cx.listener(Self::cancel_editing))
.into_any_element() .into_any_element()
} else { } else {
primary 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 { fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
if self.thread().map_or(true, |thread| { if self.thread().map_or(true, |thread| {
thread.read(cx).status() == ThreadStatus::Idle thread.read(cx).status() == ThreadStatus::Idle