Restore ACP slash commands when reopening threads (#53209)
Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the UI/UX checklist - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #52239 ## Summary Note - The code in this fix is AI generated. OpenCode ACP chats lost slash-command support after Zed restarted and a thread was reopened. The UI no longer showed `/ for commands`, and slash commands like `/help` were treated as unsupported. ## Root Cause ACP available commands were treated as transient UI state instead of durable thread state. - `AcpThread` handled `AvailableCommandsUpdate` but did not retain the commands on the thread - restored thread views rebuilt `SessionCapabilities` with an empty `available_commands` list - the message-editor placeholder started in the wrong state for restored threads - live command updates could be applied to the wrong thread view ## Fix - persisted `available_commands` on `AcpThread` - restored `SessionCapabilities` from thread state - reused the same command augmentation logic for restore and live update paths - updated live command handling to target the correct thread view - initialized the message-editor placeholder from current command availability - added a regression test for the restore path ## Verification - `cargo test -p agent_ui conversation_view::tests::test_restored_threads_keep_available_commands -- --exact --nocapture` - `./script/clippy -p agent_ui --tests` Release Notes: - Fixed ACP slash commands disappearing after reopening restored threads. --------- Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
1 parent
a018333d41
commit
3a5dc8ef6a
3 files changed
+191
-37
No files matched your search
@@ -1032,6 +1032,7 @@ pub struct AcpThread {
|
||||
connection: Rc<dyn AgentConnection>,
|
||||
token_usage: Option<TokenUsage>,
|
||||
prompt_capabilities: acp::PromptCapabilities,
|
||||
available_commands: Vec<acp::AvailableCommand>,
|
||||
_observe_prompt_capabilities: Task<anyhow::Result<()>>,
|
||||
terminals: HashMap<acp::TerminalId, Entity<Terminal>>,
|
||||
pending_terminal_output: HashMap<acp::TerminalId, Vec<Vec<u8>>>,
|
||||
@@ -1220,6 +1221,7 @@ impl AcpThread {
|
||||
session_id,
|
||||
token_usage: None,
|
||||
prompt_capabilities,
|
||||
available_commands: Vec::new(),
|
||||
_observe_prompt_capabilities: task,
|
||||
terminals: HashMap::default(),
|
||||
pending_terminal_output: HashMap::default(),
|
||||
@@ -1239,6 +1241,10 @@ impl AcpThread {
|
||||
self.prompt_capabilities.clone()
|
||||
}
|
||||
|
||||
pub fn available_commands(&self) -> &[acp::AvailableCommand] {
|
||||
&self.available_commands
|
||||
}
|
||||
|
||||
pub fn draft_prompt(&self) -> Option<&[acp::ContentBlock]> {
|
||||
self.draft_prompt.as_deref()
|
||||
}
|
||||
@@ -1419,7 +1425,10 @@ impl AcpThread {
|
||||
acp::SessionUpdate::AvailableCommandsUpdate(acp::AvailableCommandsUpdate {
|
||||
available_commands,
|
||||
..
|
||||
}) => cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands)),
|
||||
}) => {
|
||||
self.available_commands = available_commands.clone();
|
||||
cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands));
|
||||
}
|
||||
acp::SessionUpdate::CurrentModeUpdate(acp::CurrentModeUpdate {
|
||||
current_mode_id,
|
||||
..
|
||||
|
||||
@@ -812,7 +812,7 @@ impl ConversationView {
|
||||
let agent_id = self.agent.agent_id();
|
||||
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new(
|
||||
thread.read(cx).prompt_capabilities(),
|
||||
vec![],
|
||||
thread.read(cx).available_commands().to_vec(),
|
||||
)));
|
||||
|
||||
let action_log = thread.read(cx).action_log().clone();
|
||||
@@ -1448,40 +1448,24 @@ impl ConversationView {
|
||||
self.emit_token_limit_telemetry_if_needed(thread, cx);
|
||||
}
|
||||
AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
|
||||
let mut available_commands = available_commands.clone();
|
||||
if let Some(thread_view) = self.thread_view(&thread_id) {
|
||||
let has_commands = !available_commands.is_empty();
|
||||
|
||||
if thread
|
||||
.read(cx)
|
||||
.connection()
|
||||
.auth_methods()
|
||||
.iter()
|
||||
.any(|method| method.id().0.as_ref() == "claude-login")
|
||||
{
|
||||
available_commands.push(acp::AvailableCommand::new("login", "Authenticate"));
|
||||
available_commands.push(acp::AvailableCommand::new("logout", "Authenticate"));
|
||||
}
|
||||
let agent_display_name = self
|
||||
.agent_server_store
|
||||
.read(cx)
|
||||
.agent_display_name(&self.agent.agent_id())
|
||||
.unwrap_or_else(|| self.agent.agent_id().0.to_string().into());
|
||||
|
||||
let has_commands = !available_commands.is_empty();
|
||||
if let Some(active) = self.active_thread() {
|
||||
active.update(cx, |active, _cx| {
|
||||
active
|
||||
.session_capabilities
|
||||
.write()
|
||||
.set_available_commands(available_commands);
|
||||
});
|
||||
}
|
||||
|
||||
let agent_display_name = self
|
||||
.agent_server_store
|
||||
.read(cx)
|
||||
.agent_display_name(&self.agent.agent_id())
|
||||
.unwrap_or_else(|| self.agent.agent_id().0.to_string().into());
|
||||
|
||||
if let Some(active) = self.active_thread() {
|
||||
let new_placeholder =
|
||||
placeholder_text(agent_display_name.as_ref(), has_commands);
|
||||
active.update(cx, |active, cx| {
|
||||
active.message_editor.update(cx, |editor, cx| {
|
||||
|
||||
thread_view.update(cx, |thread_view, cx| {
|
||||
thread_view
|
||||
.session_capabilities
|
||||
.write()
|
||||
.set_available_commands(available_commands.clone());
|
||||
thread_view.message_editor.update(cx, |editor, cx| {
|
||||
editor.set_placeholder_text(&new_placeholder, window, cx);
|
||||
});
|
||||
});
|
||||
@@ -2348,9 +2332,9 @@ impl ConversationView {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
fn play_notification_sound(&self, window: &Window, cx: &mut App) {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
let _visible = window.is_window_active()
|
||||
let visible = window.is_window_active()
|
||||
&& if let Some(mw) = window.root::<MultiWorkspace>().flatten() {
|
||||
self.agent_panel_visible(&mw, cx)
|
||||
} else {
|
||||
@@ -2358,8 +2342,8 @@ impl ConversationView {
|
||||
.upgrade()
|
||||
.is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx))
|
||||
};
|
||||
#[cfg(feature = "audio")]
|
||||
if settings.play_sound_when_agent_done.should_play(_visible) {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
if settings.play_sound_when_agent_done.should_play(visible) {
|
||||
Audio::play_sound(Sound::AgentDone, cx);
|
||||
}
|
||||
}
|
||||
@@ -2989,6 +2973,166 @@ pub(crate) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RestoredAvailableCommandsConnection;
|
||||
|
||||
impl AgentConnection for RestoredAvailableCommandsConnection {
|
||||
fn agent_id(&self) -> AgentId {
|
||||
AgentId::new("restored-available-commands")
|
||||
}
|
||||
|
||||
fn telemetry_id(&self) -> SharedString {
|
||||
"restored-available-commands".into()
|
||||
}
|
||||
|
||||
fn new_session(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
_work_dirs: PathList,
|
||||
cx: &mut App,
|
||||
) -> Task<gpui::Result<Entity<AcpThread>>> {
|
||||
let thread = build_test_thread(
|
||||
self,
|
||||
project,
|
||||
"RestoredAvailableCommandsConnection",
|
||||
SessionId::new("new-session"),
|
||||
cx,
|
||||
);
|
||||
Task::ready(Ok(thread))
|
||||
}
|
||||
|
||||
fn supports_load_session(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn load_session(
|
||||
self: Rc<Self>,
|
||||
session_id: acp::SessionId,
|
||||
project: Entity<Project>,
|
||||
_work_dirs: PathList,
|
||||
_title: Option<SharedString>,
|
||||
cx: &mut App,
|
||||
) -> Task<gpui::Result<Entity<AcpThread>>> {
|
||||
let thread = build_test_thread(
|
||||
self,
|
||||
project,
|
||||
"RestoredAvailableCommandsConnection",
|
||||
session_id,
|
||||
cx,
|
||||
);
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.handle_session_update(
|
||||
acp::SessionUpdate::AvailableCommandsUpdate(
|
||||
acp::AvailableCommandsUpdate::new(vec![acp::AvailableCommand::new(
|
||||
"help", "Get help",
|
||||
)]),
|
||||
),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.expect("available commands update should succeed");
|
||||
|
||||
Task::ready(Ok(thread))
|
||||
}
|
||||
|
||||
fn auth_methods(&self) -> &[acp::AuthMethod] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn authenticate(
|
||||
&self,
|
||||
_method_id: acp::AuthMethodId,
|
||||
_cx: &mut App,
|
||||
) -> Task<gpui::Result<()>> {
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
|
||||
fn prompt(
|
||||
&self,
|
||||
_id: Option<acp_thread::UserMessageId>,
|
||||
_params: acp::PromptRequest,
|
||||
_cx: &mut App,
|
||||
) -> Task<gpui::Result<acp::PromptResponse>> {
|
||||
Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
|
||||
}
|
||||
|
||||
fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_restored_threads_keep_available_commands(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
let project = Project::test(fs, [], 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 thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
|
||||
let connection_store =
|
||||
cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
|
||||
|
||||
let conversation_view = cx.update(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
ConversationView::new(
|
||||
Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)),
|
||||
connection_store,
|
||||
Agent::Custom { id: "Test".into() },
|
||||
Some(SessionId::new("restored-session")),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
workspace.downgrade(),
|
||||
project,
|
||||
Some(thread_store),
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let message_editor = message_editor(&conversation_view, cx);
|
||||
let editor =
|
||||
message_editor.update(cx, |message_editor, _cx| message_editor.editor().clone());
|
||||
let placeholder = editor.update(cx, |editor, cx| editor.placeholder_text(cx));
|
||||
|
||||
active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
|
||||
let available_commands = view
|
||||
.session_capabilities
|
||||
.read()
|
||||
.available_commands()
|
||||
.to_vec();
|
||||
assert_eq!(available_commands.len(), 1);
|
||||
assert_eq!(available_commands[0].name.as_str(), "help");
|
||||
assert_eq!(available_commands[0].description.as_str(), "Get help");
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
placeholder,
|
||||
Some("Message Test — @ to include context, / for commands".to_string())
|
||||
);
|
||||
|
||||
message_editor.update_in(cx, |editor, window, cx| {
|
||||
editor.set_text("/help", window, cx);
|
||||
});
|
||||
|
||||
let contents_result = message_editor
|
||||
.update(cx, |editor, cx| editor.contents(false, cx))
|
||||
.await;
|
||||
|
||||
assert!(contents_result.is_ok());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_resume_thread_uses_session_cwd_when_inside_project(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
@@ -344,7 +344,8 @@ impl ThreadView {
|
||||
) -> Self {
|
||||
let id = thread.read(cx).session_id().clone();
|
||||
|
||||
let placeholder = placeholder_text(agent_display_name.as_ref(), false);
|
||||
let has_commands = !session_capabilities.read().available_commands().is_empty();
|
||||
let placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
|
||||
|
||||
let history_subscription = history.as_ref().map(|h| {
|
||||
cx.observe(h, |this, history, cx| {
|
||||
|
||||
Reference in new issue
Block a user