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:
Yoni SiroteandBen Brandt authored and GitHub committed 2026-04-06 22:08:49 +00:00
1 parent a018333d41
commit 3a5dc8ef6a
3 files changed
+191 -37

No files matched your search

+10 -1
View File
@@ -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,
..
+179 -35
View File
@@ -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| {