From 3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59 Mon Sep 17 00:00:00 2001 From: Yoni Sirote <96873891+yonisirote@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:08:49 +0300 Subject: [PATCH] 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 --- crates/acp_thread/src/acp_thread.rs | 11 +- crates/agent_ui/src/conversation_view.rs | 214 +++++++++++++++--- .../src/conversation_view/thread_view.rs | 3 +- 3 files changed, 191 insertions(+), 37 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 0bcb8254c8b8123eef3faaa913bb360de8dcc76d..36c9fb40c4a573e09da05618a29c1898cced60ad 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1032,6 +1032,7 @@ pub struct AcpThread { connection: Rc, token_usage: Option, prompt_capabilities: acp::PromptCapabilities, + available_commands: Vec, _observe_prompt_capabilities: Task>, terminals: HashMap>, pending_terminal_output: HashMap>>, @@ -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, .. diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 149ed2e2fc0f9b22244e0d69deebf5aa7bb7d4c5..7c9acfdf27d5b750afe4b8817af7f657f5fcdecc 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -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 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()); + 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::().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, + project: Entity, + _work_dirs: PathList, + cx: &mut App, + ) -> Task>> { + 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, + session_id: acp::SessionId, + project: Entity, + _work_dirs: PathList, + _title: Option, + cx: &mut App, + ) -> Task>> { + 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> { + Task::ready(Ok(())) + } + + fn prompt( + &self, + _id: Option, + _params: acp::PromptRequest, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + } + + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} + + fn into_any(self: Rc) -> Rc { + 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); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 25af09832f3473aa690c7b205e1b56bab86e9709..9f9b5dff00536953b76a50b65a4ab64e427bc554 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -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| {