acp: Stay in edit mode when current completion ends (#36413)
When a turn ends and the checkpoint is updated, `AcpThread` emits `EntryUpdated` with the index of the user message. This was causing the message editor to be recreated and, therefore, lose focus. Release Notes: - N/A
This commit is contained in:
parent
6bf666958c
commit
db31fa67f3
4 changed files with 214 additions and 70 deletions
|
@ -670,6 +670,7 @@ pub struct AcpThread {
|
||||||
session_id: acp::SessionId,
|
session_id: acp::SessionId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub enum AcpThreadEvent {
|
pub enum AcpThreadEvent {
|
||||||
NewEntry,
|
NewEntry,
|
||||||
EntryUpdated(usize),
|
EntryUpdated(usize),
|
||||||
|
|
|
@ -186,7 +186,7 @@ mod test_support {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use collections::HashMap;
|
use collections::HashMap;
|
||||||
use futures::future::try_join_all;
|
use futures::{channel::oneshot, future::try_join_all};
|
||||||
use gpui::{AppContext as _, WeakEntity};
|
use gpui::{AppContext as _, WeakEntity};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
@ -194,11 +194,16 @@ mod test_support {
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct StubAgentConnection {
|
pub struct StubAgentConnection {
|
||||||
sessions: Arc<Mutex<HashMap<acp::SessionId, WeakEntity<AcpThread>>>>,
|
sessions: Arc<Mutex<HashMap<acp::SessionId, Session>>>,
|
||||||
permission_requests: HashMap<acp::ToolCallId, Vec<acp::PermissionOption>>,
|
permission_requests: HashMap<acp::ToolCallId, Vec<acp::PermissionOption>>,
|
||||||
next_prompt_updates: Arc<Mutex<Vec<acp::SessionUpdate>>>,
|
next_prompt_updates: Arc<Mutex<Vec<acp::SessionUpdate>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
thread: WeakEntity<AcpThread>,
|
||||||
|
response_tx: Option<oneshot::Sender<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
impl StubAgentConnection {
|
impl StubAgentConnection {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
@ -226,15 +231,33 @@ mod test_support {
|
||||||
update: acp::SessionUpdate,
|
update: acp::SessionUpdate,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
|
assert!(
|
||||||
|
self.next_prompt_updates.lock().is_empty(),
|
||||||
|
"Use either send_update or set_next_prompt_updates"
|
||||||
|
);
|
||||||
|
|
||||||
self.sessions
|
self.sessions
|
||||||
.lock()
|
.lock()
|
||||||
.get(&session_id)
|
.get(&session_id)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
.thread
|
||||||
.update(cx, |thread, cx| {
|
.update(cx, |thread, cx| {
|
||||||
thread.handle_session_update(update.clone(), cx).unwrap();
|
thread.handle_session_update(update.clone(), cx).unwrap();
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn end_turn(&self, session_id: acp::SessionId) {
|
||||||
|
self.sessions
|
||||||
|
.lock()
|
||||||
|
.get_mut(&session_id)
|
||||||
|
.unwrap()
|
||||||
|
.response_tx
|
||||||
|
.take()
|
||||||
|
.expect("No pending turn")
|
||||||
|
.send(())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentConnection for StubAgentConnection {
|
impl AgentConnection for StubAgentConnection {
|
||||||
|
@ -251,7 +274,13 @@ mod test_support {
|
||||||
let session_id = acp::SessionId(self.sessions.lock().len().to_string().into());
|
let session_id = acp::SessionId(self.sessions.lock().len().to_string().into());
|
||||||
let thread =
|
let thread =
|
||||||
cx.new(|cx| AcpThread::new("Test", self.clone(), project, session_id.clone(), cx));
|
cx.new(|cx| AcpThread::new("Test", self.clone(), project, session_id.clone(), cx));
|
||||||
self.sessions.lock().insert(session_id, thread.downgrade());
|
self.sessions.lock().insert(
|
||||||
|
session_id,
|
||||||
|
Session {
|
||||||
|
thread: thread.downgrade(),
|
||||||
|
response_tx: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
Task::ready(Ok(thread))
|
Task::ready(Ok(thread))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -269,43 +298,59 @@ mod test_support {
|
||||||
params: acp::PromptRequest,
|
params: acp::PromptRequest,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Task<gpui::Result<acp::PromptResponse>> {
|
) -> Task<gpui::Result<acp::PromptResponse>> {
|
||||||
let sessions = self.sessions.lock();
|
let mut sessions = self.sessions.lock();
|
||||||
let thread = sessions.get(¶ms.session_id).unwrap();
|
let Session {
|
||||||
|
thread,
|
||||||
|
response_tx,
|
||||||
|
} = sessions.get_mut(¶ms.session_id).unwrap();
|
||||||
let mut tasks = vec![];
|
let mut tasks = vec![];
|
||||||
for update in self.next_prompt_updates.lock().drain(..) {
|
if self.next_prompt_updates.lock().is_empty() {
|
||||||
let thread = thread.clone();
|
let (tx, rx) = oneshot::channel();
|
||||||
let update = update.clone();
|
response_tx.replace(tx);
|
||||||
let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = &update
|
cx.spawn(async move |_| {
|
||||||
&& let Some(options) = self.permission_requests.get(&tool_call.id)
|
rx.await?;
|
||||||
{
|
Ok(acp::PromptResponse {
|
||||||
Some((tool_call.clone(), options.clone()))
|
stop_reason: acp::StopReason::EndTurn,
|
||||||
} else {
|
})
|
||||||
None
|
|
||||||
};
|
|
||||||
let task = cx.spawn(async move |cx| {
|
|
||||||
if let Some((tool_call, options)) = permission_request {
|
|
||||||
let permission = thread.update(cx, |thread, cx| {
|
|
||||||
thread.request_tool_call_authorization(
|
|
||||||
tool_call.clone().into(),
|
|
||||||
options.clone(),
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
permission?.await?;
|
|
||||||
}
|
|
||||||
thread.update(cx, |thread, cx| {
|
|
||||||
thread.handle_session_update(update.clone(), cx).unwrap();
|
|
||||||
})?;
|
|
||||||
anyhow::Ok(())
|
|
||||||
});
|
|
||||||
tasks.push(task);
|
|
||||||
}
|
|
||||||
cx.spawn(async move |_| {
|
|
||||||
try_join_all(tasks).await?;
|
|
||||||
Ok(acp::PromptResponse {
|
|
||||||
stop_reason: acp::StopReason::EndTurn,
|
|
||||||
})
|
})
|
||||||
})
|
} else {
|
||||||
|
for update in self.next_prompt_updates.lock().drain(..) {
|
||||||
|
let thread = thread.clone();
|
||||||
|
let update = update.clone();
|
||||||
|
let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) =
|
||||||
|
&update
|
||||||
|
&& let Some(options) = self.permission_requests.get(&tool_call.id)
|
||||||
|
{
|
||||||
|
Some((tool_call.clone(), options.clone()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let task = cx.spawn(async move |cx| {
|
||||||
|
if let Some((tool_call, options)) = permission_request {
|
||||||
|
let permission = thread.update(cx, |thread, cx| {
|
||||||
|
thread.request_tool_call_authorization(
|
||||||
|
tool_call.clone().into(),
|
||||||
|
options.clone(),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
permission?.await?;
|
||||||
|
}
|
||||||
|
thread.update(cx, |thread, cx| {
|
||||||
|
thread.handle_session_update(update.clone(), cx).unwrap();
|
||||||
|
})?;
|
||||||
|
anyhow::Ok(())
|
||||||
|
});
|
||||||
|
tasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.spawn(async move |_| {
|
||||||
|
try_join_all(tasks).await?;
|
||||||
|
Ok(acp::PromptResponse {
|
||||||
|
stop_reason: acp::StopReason::EndTurn,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
|
fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
|
||||||
|
|
|
@ -5,8 +5,8 @@ use agent::{TextThreadStore, ThreadStore};
|
||||||
use collections::HashMap;
|
use collections::HashMap;
|
||||||
use editor::{Editor, EditorMode, MinimapVisibility};
|
use editor::{Editor, EditorMode, MinimapVisibility};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, TextStyleRefinement,
|
AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, Focusable,
|
||||||
WeakEntity, Window,
|
TextStyleRefinement, WeakEntity, Window,
|
||||||
};
|
};
|
||||||
use language::language_settings::SoftWrap;
|
use language::language_settings::SoftWrap;
|
||||||
use project::Project;
|
use project::Project;
|
||||||
|
@ -61,34 +61,44 @@ impl EntryViewState {
|
||||||
AgentThreadEntry::UserMessage(message) => {
|
AgentThreadEntry::UserMessage(message) => {
|
||||||
let has_id = message.id.is_some();
|
let has_id = message.id.is_some();
|
||||||
let chunks = message.chunks.clone();
|
let chunks = message.chunks.clone();
|
||||||
let message_editor = cx.new(|cx| {
|
if let Some(Entry::UserMessage(editor)) = self.entries.get_mut(index) {
|
||||||
let mut editor = MessageEditor::new(
|
if !editor.focus_handle(cx).is_focused(window) {
|
||||||
self.workspace.clone(),
|
// Only update if we are not editing.
|
||||||
self.project.clone(),
|
// If we are, cancelling the edit will set the message to the newest content.
|
||||||
self.thread_store.clone(),
|
editor.update(cx, |editor, cx| {
|
||||||
self.text_thread_store.clone(),
|
editor.set_message(chunks, window, cx);
|
||||||
"Edit message - @ to include context",
|
});
|
||||||
editor::EditorMode::AutoHeight {
|
|
||||||
min_lines: 1,
|
|
||||||
max_lines: None,
|
|
||||||
},
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
if !has_id {
|
|
||||||
editor.set_read_only(true, cx);
|
|
||||||
}
|
}
|
||||||
editor.set_message(chunks, window, cx);
|
} else {
|
||||||
editor
|
let message_editor = cx.new(|cx| {
|
||||||
});
|
let mut editor = MessageEditor::new(
|
||||||
cx.subscribe(&message_editor, move |_, editor, event, cx| {
|
self.workspace.clone(),
|
||||||
cx.emit(EntryViewEvent {
|
self.project.clone(),
|
||||||
entry_index: index,
|
self.thread_store.clone(),
|
||||||
view_event: ViewEvent::MessageEditorEvent(editor, *event),
|
self.text_thread_store.clone(),
|
||||||
|
"Edit message - @ to include context",
|
||||||
|
editor::EditorMode::AutoHeight {
|
||||||
|
min_lines: 1,
|
||||||
|
max_lines: None,
|
||||||
|
},
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
if !has_id {
|
||||||
|
editor.set_read_only(true, cx);
|
||||||
|
}
|
||||||
|
editor.set_message(chunks, window, cx);
|
||||||
|
editor
|
||||||
|
});
|
||||||
|
cx.subscribe(&message_editor, move |_, editor, event, cx| {
|
||||||
|
cx.emit(EntryViewEvent {
|
||||||
|
entry_index: index,
|
||||||
|
view_event: ViewEvent::MessageEditorEvent(editor, *event),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
.detach();
|
||||||
.detach();
|
self.set_entry(index, Entry::UserMessage(message_editor));
|
||||||
self.set_entry(index, Entry::UserMessage(message_editor));
|
}
|
||||||
}
|
}
|
||||||
AgentThreadEntry::ToolCall(tool_call) => {
|
AgentThreadEntry::ToolCall(tool_call) => {
|
||||||
let terminals = tool_call.terminals().cloned().collect::<Vec<_>>();
|
let terminals = tool_call.terminals().cloned().collect::<Vec<_>>();
|
||||||
|
|
|
@ -3606,7 +3606,7 @@ pub(crate) mod tests {
|
||||||
async fn test_drop(cx: &mut TestAppContext) {
|
async fn test_drop(cx: &mut TestAppContext) {
|
||||||
init_test(cx);
|
init_test(cx);
|
||||||
|
|
||||||
let (thread_view, _cx) = setup_thread_view(StubAgentServer::default(), cx).await;
|
let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
|
||||||
let weak_view = thread_view.downgrade();
|
let weak_view = thread_view.downgrade();
|
||||||
drop(thread_view);
|
drop(thread_view);
|
||||||
assert!(!weak_view.is_upgradable());
|
assert!(!weak_view.is_upgradable());
|
||||||
|
@ -3616,7 +3616,7 @@ pub(crate) mod tests {
|
||||||
async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
|
async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
|
||||||
init_test(cx);
|
init_test(cx);
|
||||||
|
|
||||||
let (thread_view, cx) = setup_thread_view(StubAgentServer::default(), cx).await;
|
let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
|
||||||
|
|
||||||
let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
|
let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
|
||||||
message_editor.update_in(cx, |editor, window, cx| {
|
message_editor.update_in(cx, |editor, window, cx| {
|
||||||
|
@ -3800,8 +3800,12 @@ pub(crate) mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StubAgentServer<StubAgentConnection> {
|
impl StubAgentServer<StubAgentConnection> {
|
||||||
fn default() -> Self {
|
fn default_response() -> Self {
|
||||||
Self::new(StubAgentConnection::default())
|
let conn = StubAgentConnection::new();
|
||||||
|
conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk {
|
||||||
|
content: "Default response".into(),
|
||||||
|
}]);
|
||||||
|
Self::new(conn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4214,4 +4218,88 @@ pub(crate) mod tests {
|
||||||
assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
|
assert_eq!(new_editor.read(cx).text(cx), "Edited message content");
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_message_editing_while_generating(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let connection = StubAgentConnection::new();
|
||||||
|
|
||||||
|
let (thread_view, cx) =
|
||||||
|
setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
|
||||||
|
add_to_workspace(thread_view.clone(), cx);
|
||||||
|
|
||||||
|
let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
|
||||||
|
message_editor.update_in(cx, |editor, window, cx| {
|
||||||
|
editor.set_text("Original message to edit", window, cx);
|
||||||
|
});
|
||||||
|
thread_view.update_in(cx, |thread_view, window, cx| {
|
||||||
|
thread_view.send(window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| {
|
||||||
|
let thread = view.thread().unwrap().read(cx);
|
||||||
|
assert_eq!(thread.entries().len(), 1);
|
||||||
|
|
||||||
|
let editor = view
|
||||||
|
.entry_view_state
|
||||||
|
.read(cx)
|
||||||
|
.entry(0)
|
||||||
|
.unwrap()
|
||||||
|
.message_editor()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
(editor, thread.session_id().clone())
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus
|
||||||
|
cx.focus(&user_message_editor);
|
||||||
|
|
||||||
|
thread_view.read_with(cx, |view, _cx| {
|
||||||
|
assert_eq!(view.editing_message, Some(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
user_message_editor.update_in(cx, |editor, window, cx| {
|
||||||
|
editor.set_text("Edited message content", window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
thread_view.read_with(cx, |view, _cx| {
|
||||||
|
assert_eq!(view.editing_message, Some(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Finish streaming response
|
||||||
|
cx.update(|_, cx| {
|
||||||
|
connection.send_update(
|
||||||
|
session_id.clone(),
|
||||||
|
acp::SessionUpdate::AgentMessageChunk {
|
||||||
|
content: acp::ContentBlock::Text(acp::TextContent {
|
||||||
|
text: "Response".into(),
|
||||||
|
annotations: None,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
connection.end_turn(session_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
thread_view.read_with(cx, |view, _cx| {
|
||||||
|
assert_eq!(view.editing_message, Some(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
// Should still be editing
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
assert!(user_message_editor.focus_handle(cx).is_focused(window));
|
||||||
|
assert_eq!(thread_view.read(cx).editing_message, Some(0));
|
||||||
|
assert_eq!(
|
||||||
|
user_message_editor.read(cx).text(cx),
|
||||||
|
"Edited message content"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue