agent_ui: Fix thread title being overridden even when manually edited (#49028)

Danilo Leal and Bennet Bo Fenner created

This PR actually fixes two issues:
- the thread title being overridden even after it's been manually
edited; as you go and come back from the thread history view, the edited
title would get swapped for the auto-summarized one
- the parent thread title sometimes displaying the title of a subagent
thread; this would also override the manual edit

- - -

- [x] Tests
- [x] Code Reviewed
- [x] Manual QA

Release Notes:

- Agent: Fixed thread titles being overridden even when manually edited.

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>

Change summary

crates/acp_thread/src/connection.rs                  | 16 ++
crates/agent/src/agent.rs                            | 27 ++--
crates/agent_ui/src/acp/thread_view.rs               | 80 ++++++++-----
crates/agent_ui/src/acp/thread_view/active_thread.rs | 21 ++
crates/agent_ui/src/agent_panel.rs                   |  2 
5 files changed, 97 insertions(+), 49 deletions(-)

Detailed changes

crates/acp_thread/src/connection.rs 🔗

@@ -727,6 +727,14 @@ mod test_support {
             }
         }
 
+        fn set_title(
+            &self,
+            _session_id: &acp::SessionId,
+            _cx: &App,
+        ) -> Option<Rc<dyn AgentSessionSetTitle>> {
+            Some(Rc::new(StubAgentSessionSetTitle))
+        }
+
         fn truncate(
             &self,
             _session_id: &agent_client_protocol::SessionId,
@@ -740,6 +748,14 @@ mod test_support {
         }
     }
 
+    struct StubAgentSessionSetTitle;
+
+    impl AgentSessionSetTitle for StubAgentSessionSetTitle {
+        fn run(&self, _title: SharedString, _cx: &mut App) -> Task<Result<()>> {
+            Task::ready(Ok(()))
+        }
+    }
+
     struct StubAgentSessionEditor;
 
     impl AgentSessionTruncate for StubAgentSessionEditor {

crates/agent/src/agent.rs 🔗

@@ -1395,12 +1395,19 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
     fn set_title(
         &self,
         session_id: &acp::SessionId,
-        _cx: &App,
+        cx: &App,
     ) -> Option<Rc<dyn acp_thread::AgentSessionSetTitle>> {
-        Some(Rc::new(NativeAgentSessionSetTitle {
-            connection: self.clone(),
-            session_id: session_id.clone(),
-        }) as _)
+        self.0.read_with(cx, |agent, _cx| {
+            agent
+                .sessions
+                .get(session_id)
+                .filter(|s| !s.thread.read(cx).is_subagent())
+                .map(|session| {
+                    Rc::new(NativeAgentSessionSetTitle {
+                        thread: session.thread.clone(),
+                    }) as _
+                })
+        })
     }
 
     fn session_list(&self, cx: &mut App) -> Option<Rc<dyn AgentSessionList>> {
@@ -1559,17 +1566,13 @@ impl acp_thread::AgentSessionRetry for NativeAgentSessionRetry {
 }
 
 struct NativeAgentSessionSetTitle {
-    connection: NativeAgentConnection,
-    session_id: acp::SessionId,
+    thread: Entity<Thread>,
 }
 
 impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle {
     fn run(&self, title: SharedString, cx: &mut App) -> Task<Result<()>> {
-        let Some(session) = self.connection.0.read(cx).sessions.get(&self.session_id) else {
-            return Task::ready(Err(anyhow!("session not found")));
-        };
-        let thread = session.thread.clone();
-        thread.update(cx, |thread, cx| thread.set_title(title, cx));
+        self.thread
+            .update(cx, |thread, cx| thread.set_title(title, cx));
         Task::ready(Ok(()))
     }
 }

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -723,7 +723,7 @@ impl AcpServerView {
                 });
         }
 
-        let mut subscriptions = vec![
+        let subscriptions = vec![
             cx.subscribe_in(&thread, window, Self::handle_thread_event),
             cx.observe(&action_log, |_, _, cx| cx.notify()),
         ];
@@ -755,18 +755,6 @@ impl AcpServerView {
             .detach();
         }
 
-        let title_editor = if thread.update(cx, |thread, cx| thread.can_set_title(cx)) {
-            let editor = cx.new(|cx| {
-                let mut editor = Editor::single_line(window, cx);
-                editor.set_text(thread.read(cx).title(), window, cx);
-                editor
-            });
-            subscriptions.push(cx.subscribe_in(&editor, window, Self::handle_title_editor_event));
-            Some(editor)
-        } else {
-            None
-        };
-
         let profile_selector: Option<Rc<agent::NativeAgentConnection>> =
             connection.clone().downcast();
         let profile_selector = profile_selector
@@ -802,7 +790,6 @@ impl AcpServerView {
                 agent_display_name,
                 self.workspace.clone(),
                 entry_view_state,
-                title_editor,
                 config_options_view,
                 mode_selector,
                 model_selector,
@@ -984,20 +971,6 @@ impl AcpServerView {
         }
     }
 
-    pub fn handle_title_editor_event(
-        &mut self,
-        title_editor: &Entity<Editor>,
-        event: &EditorEvent,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        if let Some(active) = self.active_thread() {
-            active.update(cx, |active, cx| {
-                active.handle_title_editor_event(title_editor, event, window, cx);
-            });
-        }
-    }
-
     pub fn is_loading(&self) -> bool {
         matches!(self.server_state, ServerState::Loading { .. })
     }
@@ -1181,10 +1154,8 @@ impl AcpServerView {
             }
             AcpThreadEvent::TitleUpdated => {
                 let title = thread.read(cx).title();
-                if let Some(title_editor) = self
-                    .thread_view(&thread_id)
-                    .and_then(|active| active.read(cx).title_editor.clone())
-                {
+                if let Some(active_thread) = self.thread_view(&thread_id) {
+                    let title_editor = active_thread.read(cx).title_editor.clone();
                     title_editor.update(cx, |editor, cx| {
                         if editor.text(cx) != title {
                             editor.set_text(title, window, cx);
@@ -5799,4 +5770,49 @@ pub(crate) mod tests {
             "Missing deny pattern option"
         );
     }
+
+    #[gpui::test]
+    async fn test_manually_editing_title_updates_acp_thread_title(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
+
+        let active = active_thread(&thread_view, cx);
+        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
+        let thread = cx.read(|cx| active.read(cx).thread.clone());
+
+        title_editor.read_with(cx, |editor, cx| {
+            assert!(!editor.read_only(cx));
+        });
+
+        title_editor.update_in(cx, |editor, window, cx| {
+            editor.set_text("My Custom Title", window, cx);
+        });
+        cx.run_until_parked();
+
+        title_editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.text(cx), "My Custom Title");
+        });
+        thread.read_with(cx, |thread, _cx| {
+            assert_eq!(thread.title().as_ref(), "My Custom Title");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_title_editor_is_read_only_when_set_title_unsupported(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let (thread_view, cx) =
+            setup_thread_view(StubAgentServer::new(ResumeOnlyAgentConnection), cx).await;
+
+        let active = active_thread(&thread_view, cx);
+        let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
+
+        title_editor.read_with(cx, |editor, cx| {
+            assert!(
+                editor.read_only(cx),
+                "Title editor should be read-only when the connection does not support set_title"
+            );
+        });
+    }
 }

crates/agent_ui/src/acp/thread_view/active_thread.rs 🔗

@@ -176,7 +176,7 @@ pub struct AcpThreadView {
     pub focus_handle: FocusHandle,
     pub workspace: WeakEntity<Workspace>,
     pub entry_view_state: Entity<EntryViewState>,
-    pub title_editor: Option<Entity<Editor>>,
+    pub title_editor: Entity<Editor>,
     pub config_options_view: Option<Entity<ConfigOptionsView>>,
     pub mode_selector: Option<Entity<ModeSelector>>,
     pub model_selector: Option<Entity<AcpModelSelectorPopover>>,
@@ -266,7 +266,6 @@ impl AcpThreadView {
         agent_display_name: SharedString,
         workspace: WeakEntity<Workspace>,
         entry_view_state: Entity<EntryViewState>,
-        title_editor: Option<Entity<Editor>>,
         config_options_view: Option<Entity<ConfigOptionsView>>,
         mode_selector: Option<Entity<ModeSelector>>,
         model_selector: Option<Entity<AcpModelSelectorPopover>>,
@@ -332,6 +331,18 @@ impl AcpThreadView {
             && project.upgrade().is_some_and(|p| p.read(cx).is_local())
             && agent_name == "Codex";
 
+        let title_editor = {
+            let can_edit = thread.update(cx, |thread, cx| thread.can_set_title(cx));
+            let editor = cx.new(|cx| {
+                let mut editor = Editor::single_line(window, cx);
+                editor.set_text(thread.read(cx).title(), window, cx);
+                editor.set_read_only(!can_edit);
+                editor
+            });
+            subscriptions.push(cx.subscribe_in(&editor, window, Self::handle_title_editor_event));
+            editor
+        };
+
         subscriptions.push(cx.subscribe_in(
             &entry_view_state,
             window,
@@ -2303,7 +2314,6 @@ impl AcpThreadView {
             return None;
         };
 
-        let title = self.thread.read(cx).title();
         let server_view = self.server_view.clone();
 
         let is_done = self.thread.read(cx).status() == ThreadStatus::Idle;
@@ -2315,17 +2325,20 @@ impl AcpThreadView {
                 .pr_1p5()
                 .w_full()
                 .justify_between()
+                .gap_1()
                 .border_b_1()
                 .border_color(cx.theme().colors().border)
                 .bg(cx.theme().colors().editor_background.opacity(0.2))
                 .child(
                     h_flex()
+                        .flex_1()
+                        .gap_2()
                         .child(
                             Icon::new(IconName::ForwardArrowUp)
                                 .size(IconSize::Small)
                                 .color(Color::Muted),
                         )
-                        .child(Label::new(title).color(Color::Muted).ml_2().mr_1())
+                        .child(self.title_editor.clone())
                         .when(is_done, |this| {
                             this.child(Icon::new(IconName::Check).color(Color::Success))
                         }),

crates/agent_ui/src/agent_panel.rs 🔗

@@ -1954,7 +1954,7 @@ impl AgentPanel {
                 if let Some(title_editor) = thread_view
                     .read(cx)
                     .parent_thread(cx)
-                    .and_then(|r| r.read(cx).title_editor.clone())
+                    .map(|r| r.read(cx).title_editor.clone())
                 {
                     let container = div()
                         .w_full()