agent_ui: Don't reset provisional title if we already have one (#52193)

Ben Brandt and Bennet Bo Fenner created

## Context

If you went back and edited the first message, we were resetting the
provisional title, even though the title had previously already been set
from a summarization pass.

In order to achieve this, we moved the default thread title logic up the
UI layer, and allowed AcpThreads to have an optional title.

## Self-Review Checklist

<!-- Check before requesting review: -->
- [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](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

---------

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

Change summary

crates/acp_thread/src/acp_thread.rs                  | 39 ++++++++-----
crates/acp_thread/src/connection.rs                  |  3 
crates/agent/src/agent.rs                            | 18 +++--
crates/agent/src/tests/mod.rs                        | 10 ++-
crates/agent/src/thread.rs                           |  6 +-
crates/agent_servers/src/acp.rs                      |  9 --
crates/agent_servers/src/custom.rs                   |  6 --
crates/agent_ui/src/agent_diff.rs                    | 31 ++++------
crates/agent_ui/src/agent_panel.rs                   | 14 ++--
crates/agent_ui/src/agent_ui.rs                      |  2 
crates/agent_ui/src/completion_provider.rs           |  9 +-
crates/agent_ui/src/conversation_view.rs             | 33 ++++++-----
crates/agent_ui/src/conversation_view/thread_view.rs | 19 ++++--
crates/agent_ui/src/message_editor.rs                |  3 
crates/agent_ui/src/thread_history_view.rs           | 18 +++---
crates/agent_ui/src/thread_metadata_store.rs         |  8 ++
crates/sidebar/src/sidebar.rs                        | 14 +++-
17 files changed, 129 insertions(+), 113 deletions(-)

Detailed changes

crates/acp_thread/src/acp_thread.rs πŸ”—

@@ -1013,7 +1013,7 @@ pub struct AcpThread {
     session_id: acp::SessionId,
     work_dirs: Option<PathList>,
     parent_session_id: Option<acp::SessionId>,
-    title: SharedString,
+    title: Option<SharedString>,
     provisional_title: Option<SharedString>,
     entries: Vec<AgentThreadEntry>,
     plan: Plan,
@@ -1176,7 +1176,7 @@ impl Error for LoadError {}
 impl AcpThread {
     pub fn new(
         parent_session_id: Option<acp::SessionId>,
-        title: impl Into<SharedString>,
+        title: Option<SharedString>,
         work_dirs: Option<PathList>,
         connection: Rc<dyn AgentConnection>,
         project: Entity<Project>,
@@ -1203,7 +1203,7 @@ impl AcpThread {
             shared_buffers: Default::default(),
             entries: Default::default(),
             plan: Default::default(),
-            title: title.into(),
+            title,
             provisional_title: None,
             project,
             running_turn: None,
@@ -1259,10 +1259,10 @@ impl AcpThread {
         &self.project
     }
 
-    pub fn title(&self) -> SharedString {
-        self.provisional_title
+    pub fn title(&self) -> Option<SharedString> {
+        self.title
             .clone()
-            .unwrap_or_else(|| self.title.clone())
+            .or_else(|| self.provisional_title.clone())
     }
 
     pub fn has_provisional_title(&self) -> bool {
@@ -1387,8 +1387,8 @@ impl AcpThread {
                 if let acp::MaybeUndefined::Value(title) = info_update.title {
                     let had_provisional = self.provisional_title.take().is_some();
                     let title: SharedString = title.into();
-                    if title != self.title {
-                        self.title = title;
+                    if self.title.as_ref() != Some(&title) {
+                        self.title = Some(title);
                         cx.emit(AcpThreadEvent::TitleUpdated);
                     } else if had_provisional {
                         cx.emit(AcpThreadEvent::TitleUpdated);
@@ -1676,8 +1676,8 @@ impl AcpThread {
 
     pub fn set_title(&mut self, title: SharedString, cx: &mut Context<Self>) -> Task<Result<()>> {
         let had_provisional = self.provisional_title.take().is_some();
-        if title != self.title {
-            self.title = title.clone();
+        if self.title.as_ref() != Some(&title) {
+            self.title = Some(title.clone());
             cx.emit(AcpThreadEvent::TitleUpdated);
             if let Some(set_title) = self.connection.set_title(&self.session_id, cx) {
                 return set_title.run(title, cx);
@@ -4297,7 +4297,7 @@ mod tests {
             let thread = cx.new(|cx| {
                 AcpThread::new(
                     None,
-                    "Test",
+                    None,
                     Some(work_dirs),
                     self.clone(),
                     project,
@@ -4999,7 +4999,7 @@ mod tests {
 
         // Initial title is the default.
         thread.read_with(cx, |thread, _| {
-            assert_eq!(thread.title().as_ref(), "Test");
+            assert_eq!(thread.title(), None);
         });
 
         // Setting a provisional title updates the display title.
@@ -5007,7 +5007,10 @@ mod tests {
             thread.set_provisional_title("Hello, can you help…".into(), cx);
         });
         thread.read_with(cx, |thread, _| {
-            assert_eq!(thread.title().as_ref(), "Hello, can you help…");
+            assert_eq!(
+                thread.title().as_ref().map(|s| s.as_str()),
+                Some("Hello, can you help…")
+            );
         });
 
         // The provisional title should NOT have propagated to the connection.
@@ -5024,7 +5027,10 @@ mod tests {
         });
         task.await.expect("set_title should succeed");
         thread.read_with(cx, |thread, _| {
-            assert_eq!(thread.title().as_ref(), "Helping with Rust question");
+            assert_eq!(
+                thread.title().as_ref().map(|s| s.as_str()),
+                Some("Helping with Rust question")
+            );
         });
         assert_eq!(
             set_title_calls.borrow().as_slice(),
@@ -5088,7 +5094,10 @@ mod tests {
         result.expect("session info update should succeed");
 
         thread.read_with(cx, |thread, _| {
-            assert_eq!(thread.title().as_ref(), "Helping with Rust question");
+            assert_eq!(
+                thread.title().as_ref().map(|s| s.as_str()),
+                Some("Helping with Rust question")
+            );
             assert!(
                 !thread.has_provisional_title(),
                 "session info title update should clear provisional title"

crates/acp_thread/src/connection.rs πŸ”—

@@ -665,11 +665,10 @@ mod test_support {
             cx: &mut gpui::App,
         ) -> Entity<AcpThread> {
             let action_log = cx.new(|_| ActionLog::new(project.clone()));
-            let thread_title = title.unwrap_or_else(|| SharedString::new_static("Test"));
             let thread = cx.new(|cx| {
                 AcpThread::new(
                     None,
-                    thread_title,
+                    title,
                     Some(work_dirs),
                     self.clone(),
                     project,

crates/agent/src/agent.rs πŸ”—

@@ -662,14 +662,16 @@ impl NativeAgent {
         let Some(session) = self.sessions.get(session_id) else {
             return;
         };
-        let thread = thread.downgrade();
-        let acp_thread = session.acp_thread.downgrade();
-        cx.spawn(async move |_, cx| {
-            let title = thread.read_with(cx, |thread, _| thread.title())?;
-            let task = acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?;
-            task.await
-        })
-        .detach_and_log_err(cx);
+
+        if let Some(title) = thread.read(cx).title() {
+            let acp_thread = session.acp_thread.downgrade();
+            cx.spawn(async move |_, cx| {
+                let task =
+                    acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?;
+                task.await
+            })
+            .detach_and_log_err(cx);
+        }
     }
 
     fn handle_thread_token_usage_updated(

crates/agent/src/tests/mod.rs πŸ”—

@@ -3122,7 +3122,7 @@ async fn test_title_generation(cx: &mut TestAppContext) {
     fake_model.send_last_completion_stream_text_chunk("Hey!");
     fake_model.end_last_completion_stream();
     cx.run_until_parked();
-    thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "New Thread"));
+    thread.read_with(cx, |thread, _| assert_eq!(thread.title(), None));
 
     // Ensure the summary model has been invoked to generate a title.
     summary_model.send_last_completion_stream_text_chunk("Hello ");
@@ -3131,7 +3131,9 @@ async fn test_title_generation(cx: &mut TestAppContext) {
     summary_model.end_last_completion_stream();
     send.collect::<Vec<_>>().await;
     cx.run_until_parked();
-    thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "Hello world"));
+    thread.read_with(cx, |thread, _| {
+        assert_eq!(thread.title(), Some("Hello world".into()))
+    });
 
     // Send another message, ensuring no title is generated this time.
     let send = thread
@@ -3145,7 +3147,9 @@ async fn test_title_generation(cx: &mut TestAppContext) {
     cx.run_until_parked();
     assert_eq!(summary_model.pending_completions(), Vec::new());
     send.collect::<Vec<_>>().await;
-    thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "Hello world"));
+    thread.read_with(cx, |thread, _| {
+        assert_eq!(thread.title(), Some("Hello world".into()))
+    });
 }
 
 #[gpui::test]

crates/agent/src/thread.rs πŸ”—

@@ -1312,7 +1312,7 @@ impl Thread {
     pub fn to_db(&self, cx: &App) -> Task<DbThread> {
         let initial_project_snapshot = self.initial_project_snapshot.clone();
         let mut thread = DbThread {
-            title: self.title(),
+            title: self.title().unwrap_or_default(),
             messages: self.messages.clone(),
             updated_at: self.updated_at,
             detailed_summary: self.summary.clone(),
@@ -2491,8 +2491,8 @@ impl Thread {
         }
     }
 
-    pub fn title(&self) -> SharedString {
-        self.title.clone().unwrap_or("New Thread".into())
+    pub fn title(&self) -> Option<SharedString> {
+        self.title.clone()
     }
 
     pub fn is_generating_summary(&self) -> bool {

crates/agent_servers/src/acp.rs πŸ”—

@@ -42,7 +42,6 @@ pub struct UnsupportedVersion;
 
 pub struct AcpConnection {
     id: AgentId,
-    display_name: SharedString,
     telemetry_id: SharedString,
     connection: Rc<acp::ClientSideConnection>,
     sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
@@ -167,7 +166,6 @@ impl AgentSessionList for AcpSessionList {
 pub async fn connect(
     agent_id: AgentId,
     project: Entity<Project>,
-    display_name: SharedString,
     command: AgentServerCommand,
     default_mode: Option<acp::SessionModeId>,
     default_model: Option<acp::ModelId>,
@@ -177,7 +175,6 @@ pub async fn connect(
     let conn = AcpConnection::stdio(
         agent_id,
         project,
-        display_name,
         command.clone(),
         default_mode,
         default_model,
@@ -194,7 +191,6 @@ impl AcpConnection {
     pub async fn stdio(
         agent_id: AgentId,
         project: Entity<Project>,
-        display_name: SharedString,
         command: AgentServerCommand,
         default_mode: Option<acp::SessionModeId>,
         default_model: Option<acp::ModelId>,
@@ -364,7 +360,6 @@ impl AcpConnection {
             auth_methods,
             command,
             connection,
-            display_name,
             telemetry_id,
             sessions,
             agent_capabilities: response.agent_capabilities,
@@ -660,7 +655,7 @@ impl AgentConnection for AcpConnection {
             let thread: Entity<AcpThread> = cx.new(|cx| {
                 AcpThread::new(
                     None,
-                    self.display_name.clone(),
+                    None,
                     Some(work_dirs),
                     self.clone(),
                     project,
@@ -718,7 +713,6 @@ impl AgentConnection for AcpConnection {
 
         let mcp_servers = mcp_servers_for_project(&project, cx);
         let action_log = cx.new(|_| ActionLog::new(project.clone()));
-        let title = title.unwrap_or_else(|| self.display_name.clone());
         let thread: Entity<AcpThread> = cx.new(|cx| {
             AcpThread::new(
                 None,
@@ -801,7 +795,6 @@ impl AgentConnection for AcpConnection {
 
         let mcp_servers = mcp_servers_for_project(&project, cx);
         let action_log = cx.new(|_| ActionLog::new(project.clone()));
-        let title = title.unwrap_or_else(|| self.display_name.clone());
         let thread: Entity<AcpThread> = cx.new(|cx| {
             AcpThread::new(
                 None,

crates/agent_servers/src/custom.rs πŸ”—

@@ -296,11 +296,6 @@ impl AgentServer for CustomAgentServer {
         cx: &mut App,
     ) -> Task<Result<Rc<dyn AgentConnection>>> {
         let agent_id = self.agent_id();
-        let display_name = delegate
-            .store
-            .read(cx)
-            .agent_display_name(&agent_id)
-            .unwrap_or_else(|| agent_id.0.clone());
         let default_mode = self.default_mode(cx);
         let default_model = self.default_model(cx);
         let is_registry_agent = is_registry_agent(agent_id.clone(), cx);
@@ -376,7 +371,6 @@ impl AgentServer for CustomAgentServer {
             let connection = crate::acp::connect(
                 agent_id,
                 project,
-                display_name,
                 command,
                 default_mode,
                 default_model,

crates/agent_ui/src/agent_diff.rs πŸ”—

@@ -44,7 +44,6 @@ pub struct AgentDiffPane {
     thread: Entity<AcpThread>,
     focus_handle: FocusHandle,
     workspace: WeakEntity<Workspace>,
-    title: SharedString,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -113,7 +112,6 @@ impl AgentDiffPane {
                     this.handle_acp_thread_event(event, cx)
                 }),
             ],
-            title: SharedString::default(),
             multibuffer,
             editor,
             thread,
@@ -121,7 +119,6 @@ impl AgentDiffPane {
             workspace,
         };
         this.update_excerpts(window, cx);
-        this.update_title(cx);
         this
     }
 
@@ -231,17 +228,9 @@ impl AgentDiffPane {
         }
     }
 
-    fn update_title(&mut self, cx: &mut Context<Self>) {
-        let new_title = self.thread.read(cx).title();
-        if new_title != self.title {
-            self.title = new_title;
-            cx.emit(EditorEvent::TitleChanged);
-        }
-    }
-
     fn handle_acp_thread_event(&mut self, event: &AcpThreadEvent, cx: &mut Context<Self>) {
         if let AcpThreadEvent::TitleUpdated = event {
-            self.update_title(cx)
+            cx.emit(EditorEvent::TitleChanged);
         }
     }
 
@@ -534,13 +523,17 @@ impl Item for AgentDiffPane {
 
     fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
         let title = self.thread.read(cx).title();
-        Label::new(format!("Review: {}", title))
-            .color(if params.selected {
-                Color::Default
-            } else {
-                Color::Muted
-            })
-            .into_any_element()
+        Label::new(if let Some(title) = title {
+            format!("Review: {}", title)
+        } else {
+            "Review".to_string()
+        })
+        .color(if params.selected {
+            Color::Default
+        } else {
+            Color::Muted
+        })
+        .into_any_element()
     }
 
     fn telemetry_event_text(&self) -> Option<&'static str> {

crates/agent_ui/src/agent_panel.rs πŸ”—

@@ -26,7 +26,6 @@ use zed_actions::agent::{
     ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff,
 };
 
-use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal, HoldForDefault};
 use crate::{
     AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CycleStartThreadIn,
     Follow, InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread,
@@ -42,6 +41,10 @@ use crate::{
     Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread,
     NewNativeAgentThreadFromSummary,
 };
+use crate::{
+    DEFAULT_THREAD_TITLE,
+    ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal, HoldForDefault},
+};
 use crate::{
     ExpandMessageEditor, ThreadHistoryView,
     text_thread_history::{TextThreadHistory, TextThreadHistoryEvent},
@@ -92,7 +95,6 @@ use zed_actions::{
 
 const AGENT_PANEL_KEY: &str = "agent_panel";
 const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
-const DEFAULT_THREAD_TITLE: &str = "New Thread";
 
 fn read_serialized_panel(
     workspace_id: workspace::WorkspaceId,
@@ -775,11 +777,7 @@ impl AgentPanel {
             SerializedActiveThread {
                 session_id: thread.session_id().0.to_string(),
                 agent_type: self.selected_agent_type.clone(),
-                title: if title.as_ref() != DEFAULT_THREAD_TITLE {
-                    Some(title.to_string())
-                } else {
-                    None
-                },
+                title: title.map(|t| t.to_string()),
                 work_dirs: work_dirs.map(|dirs| dirs.serialize()),
             }
         });
@@ -3221,7 +3219,7 @@ impl AgentPanel {
                     .map(|r| r.read(cx).title_editor.clone())
                 {
                     if is_generating_title {
-                        Label::new("New Thread…")
+                        Label::new(DEFAULT_THREAD_TITLE)
                             .color(Color::Muted)
                             .truncate()
                             .with_animation(

crates/agent_ui/src/agent_ui.rs πŸ”—

@@ -80,6 +80,8 @@ pub(crate) use thread_history::ThreadHistory;
 pub(crate) use thread_history_view::*;
 use zed_actions;
 
+pub const DEFAULT_THREAD_TITLE: &str = "New Thread";
+
 actions!(
     agent,
     [

crates/agent_ui/src/completion_provider.rs πŸ”—

@@ -4,6 +4,7 @@ use std::path::PathBuf;
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 
+use crate::DEFAULT_THREAD_TITLE;
 use crate::ThreadHistory;
 use acp_thread::MentionUri;
 use agent_client_protocol as acp;
@@ -192,7 +193,7 @@ pub struct EntryMatch {
 fn session_title(title: Option<SharedString>) -> SharedString {
     title
         .filter(|title| !title.is_empty())
-        .unwrap_or_else(|| SharedString::new_static("New Thread"))
+        .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE))
 }
 
 #[derive(Debug, Clone)]
@@ -1098,11 +1099,11 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
 
         if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
             && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx)
+            && let Some(title) = thread.read(cx).title()
         {
-            let thread = thread.read(cx);
             mentions.insert(MentionUri::Thread {
-                id: thread.session_id().clone(),
-                name: thread.title().into(),
+                id: thread.read(cx).session_id().clone(),
+                name: title.to_string(),
             });
         }
 

crates/agent_ui/src/conversation_view.rs πŸ”—

@@ -40,6 +40,7 @@ use parking_lot::RwLock;
 use project::{AgentId, AgentServerStore, Project, ProjectEntryId};
 use prompt_store::{PromptId, PromptStore};
 
+use crate::DEFAULT_THREAD_TITLE;
 use crate::message_editor::SessionCapabilities;
 use rope::Point;
 use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore};
@@ -551,7 +552,7 @@ impl ConversationView {
                 (
                     Some(thread.session_id().clone()),
                     thread.work_dirs().cloned(),
-                    Some(thread.title()),
+                    thread.title(),
                 )
             })
             .unwrap_or((None, None, None));
@@ -1106,9 +1107,12 @@ impl ConversationView {
         &self.workspace
     }
 
-    pub fn title(&self, _cx: &App) -> SharedString {
+    pub fn title(&self, cx: &App) -> SharedString {
         match &self.server_state {
-            ServerState::Connected(_) => "New Thread".into(),
+            ServerState::Connected(view) => view
+                .active_view()
+                .and_then(|v| v.read(cx).thread.read(cx).title())
+                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()),
             ServerState::Loading(_) => "Loading…".into(),
             ServerState::LoadError { error, .. } => match error {
                 LoadError::Unsupported { .. } => {
@@ -1350,8 +1354,9 @@ impl ConversationView {
                 );
             }
             AcpThreadEvent::TitleUpdated => {
-                let title = thread.read(cx).title();
-                if let Some(active_thread) = self.thread_view(&thread_id) {
+                if let Some(title) = thread.read(cx).title()
+                    && 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 {
@@ -3708,7 +3713,7 @@ pub(crate) mod tests {
         cx.new(|cx| {
             AcpThread::new(
                 None,
-                name,
+                Some(name.into()),
                 None,
                 connection,
                 project,
@@ -3908,7 +3913,7 @@ pub(crate) mod tests {
             Task::ready(Ok(cx.new(|cx| {
                 AcpThread::new(
                     None,
-                    "AuthGatedAgent",
+                    None,
                     Some(work_dirs),
                     self,
                     project,
@@ -3982,7 +3987,7 @@ pub(crate) mod tests {
                 let action_log = cx.new(|_| ActionLog::new(project.clone()));
                 AcpThread::new(
                     None,
-                    "SaboteurAgentConnection",
+                    None,
                     Some(work_dirs),
                     self,
                     project,
@@ -4052,7 +4057,7 @@ pub(crate) mod tests {
                 let action_log = cx.new(|_| ActionLog::new(project.clone()));
                 AcpThread::new(
                     None,
-                    "RefusalAgentConnection",
+                    None,
                     Some(work_dirs),
                     self,
                     project,
@@ -4132,7 +4137,7 @@ pub(crate) mod tests {
             let thread = cx.new(|cx| {
                 AcpThread::new(
                     None,
-                    "CwdCapturingConnection",
+                    None,
                     Some(work_dirs),
                     self.clone(),
                     project,
@@ -4167,7 +4172,7 @@ pub(crate) mod tests {
             let thread = cx.new(|cx| {
                 AcpThread::new(
                     None,
-                    "CwdCapturingConnection",
+                    None,
                     Some(work_dirs),
                     self.clone(),
                     project,
@@ -6109,7 +6114,7 @@ pub(crate) mod tests {
             assert_eq!(editor.text(cx), "My Custom Title");
         });
         thread.read_with(cx, |thread, _cx| {
-            assert_eq!(thread.title().as_ref(), "My Custom Title");
+            assert_eq!(thread.title(), Some("My Custom Title".into()));
         });
     }
 
@@ -6195,7 +6200,7 @@ pub(crate) mod tests {
         cx.new(|cx| {
             AcpThread::new(
                 parent_session_id,
-                "Test Thread",
+                None,
                 None,
                 connection,
                 project,
@@ -6703,7 +6708,7 @@ pub(crate) mod tests {
             let thread = cx.new(|cx| {
                 AcpThread::new(
                     None,
-                    "CloseCapableConnection",
+                    Some("CloseCapableConnection".into()),
                     Some(work_dirs),
                     self,
                     project,

crates/agent_ui/src/conversation_view/thread_view.rs πŸ”—

@@ -1,4 +1,4 @@
-use crate::SelectPermissionGranularity;
+use crate::{DEFAULT_THREAD_TITLE, SelectPermissionGranularity};
 use std::cell::RefCell;
 
 use acp_thread::ContentBlock;
@@ -405,7 +405,11 @@ impl ThreadView {
             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);
+                if let Some(title) = thread.read(cx).title() {
+                    editor.set_text(title, window, cx);
+                } else {
+                    editor.set_text(DEFAULT_THREAD_TITLE, window, cx);
+                }
                 editor.set_read_only(!can_edit);
                 editor
             });
@@ -1052,7 +1056,7 @@ impl ThreadView {
                     .ok();
                 }
             });
-            if is_first_message {
+            if is_first_message && thread.read_with(cx, |thread, _cx| thread.title().is_none())? {
                 let text: String = contents
                     .iter()
                     .filter_map(|block| match block {
@@ -1537,7 +1541,7 @@ impl ThreadView {
             EditorEvent::Blurred => {
                 if title_editor.read(cx).text(cx).is_empty() {
                     title_editor.update(cx, |editor, cx| {
-                        editor.set_text("New Thread", window, cx);
+                        editor.set_text(DEFAULT_THREAD_TITLE, window, cx);
                     });
                 }
             }
@@ -4656,7 +4660,10 @@ impl ThreadView {
             .language_for_name("Markdown");
 
         let thread = self.thread.read(cx);
-        let thread_title = thread.title().to_string();
+        let thread_title = thread
+            .title()
+            .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
+            .to_string();
         let markdown = thread.to_markdown(cx);
 
         let project = workspace.read(cx).project().clone();
@@ -7068,7 +7075,7 @@ impl ThreadView {
 
         let thread_title = thread
             .as_ref()
-            .map(|t| t.read(cx).title())
+            .and_then(|t| t.read(cx).title())
             .filter(|t| !t.is_empty());
         let tool_call_label = tool_call.label.read(cx).source().to_string();
         let has_tool_call_label = !tool_call_label.is_empty();

crates/agent_ui/src/message_editor.rs πŸ”—

@@ -1,3 +1,4 @@
+use crate::DEFAULT_THREAD_TITLE;
 use crate::SendImmediately;
 use crate::ThreadHistory;
 use crate::{
@@ -387,7 +388,7 @@ impl MessageEditor {
         };
         let thread_title = title
             .filter(|title| !title.is_empty())
-            .unwrap_or_else(|| SharedString::new_static("New Thread"));
+            .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
         let uri = MentionUri::Thread {
             id: session_id,
             name: thread_title.to_string(),

crates/agent_ui/src/thread_history_view.rs πŸ”—

@@ -1,5 +1,7 @@
 use crate::thread_history::ThreadHistory;
-use crate::{AgentPanel, ConversationView, RemoveHistory, RemoveSelectedThread};
+use crate::{
+    AgentPanel, ConversationView, DEFAULT_THREAD_TITLE, RemoveHistory, RemoveSelectedThread,
+};
 use acp_thread::AgentSessionInfo;
 use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
 use editor::{Editor, EditorEvent};
@@ -16,14 +18,12 @@ use ui::{
     WithScrollbar, prelude::*,
 };
 
-const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
-
-pub(crate) fn thread_title(entry: &AgentSessionInfo) -> &SharedString {
+pub(crate) fn thread_title(entry: &AgentSessionInfo) -> SharedString {
     entry
         .title
-        .as_ref()
+        .clone()
         .filter(|title| !title.is_empty())
-        .unwrap_or(DEFAULT_TITLE)
+        .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
 }
 
 pub struct ThreadHistoryView {
@@ -203,7 +203,7 @@ impl ThreadHistoryView {
                 let mut candidates = Vec::with_capacity(entries.len());
 
                 for (idx, entry) in entries.iter().enumerate() {
-                    candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
+                    candidates.push(StringMatchCandidate::new(idx, &thread_title(entry)));
                 }
 
                 const MAX_MATCHES: usize = 100;
@@ -429,7 +429,7 @@ impl ThreadHistoryView {
             (_, None) => "β€”".to_string(),
         };
 
-        let title = thread_title(entry).clone();
+        let title = thread_title(entry);
         let full_date = entry_time
             .map(|time| {
                 EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
@@ -678,7 +678,7 @@ impl HistoryEntryElement {
 impl RenderOnce for HistoryEntryElement {
     fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
         let id = ElementId::Name(self.entry.session_id.0.clone().into());
-        let title = thread_title(&self.entry).clone();
+        let title = thread_title(&self.entry);
         let formatted_time = self
             .entry
             .updated_at

crates/agent_ui/src/thread_metadata_store.rs πŸ”—

@@ -21,6 +21,8 @@ use ui::{App, Context, SharedString};
 use util::ResultExt as _;
 use workspace::PathList;
 
+use crate::DEFAULT_THREAD_TITLE;
+
 pub fn init(cx: &mut App) {
     SidebarThreadMetadataStore::init_global(cx);
 
@@ -134,7 +136,9 @@ impl ThreadMetadata {
     pub fn from_thread(thread: &Entity<acp_thread::AcpThread>, cx: &App) -> Self {
         let thread_ref = thread.read(cx);
         let session_id = thread_ref.session_id().clone();
-        let title = thread_ref.title();
+        let title = thread_ref
+            .title()
+            .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
         let updated_at = Utc::now();
 
         let agent_id = thread_ref.connection().agent_id();
@@ -987,7 +991,7 @@ mod tests {
             cx.new(|cx| {
                 acp_thread::AcpThread::new(
                     Some(regular_session_id.clone()),
-                    "Subagent Thread",
+                    Some("Subagent Thread".into()),
                     None,
                     connection.clone(),
                     project.clone(),

crates/sidebar/src/sidebar.rs πŸ”—

@@ -5,7 +5,9 @@ use agent_ui::thread_metadata_store::{SidebarThreadMetadataStore, ThreadMetadata
 use agent_ui::threads_archive_view::{
     ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
 };
-use agent_ui::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread};
+use agent_ui::{
+    Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread,
+};
 use chrono::Utc;
 use editor::Editor;
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
@@ -559,7 +561,9 @@ impl Sidebar {
 
                 let icon = thread_view_ref.agent_icon;
                 let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
-                let title = thread.title();
+                let title = thread
+                    .title()
+                    .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
                 let is_native = thread_view_ref.as_native_thread(cx).is_some();
                 let is_title_generating = is_native && thread.has_provisional_title();
                 let session_id = thread.session_id().clone();
@@ -2709,9 +2713,9 @@ impl Sidebar {
 
         let label: SharedString = if is_active {
             self.active_draft_text(cx)
-                .unwrap_or_else(|| "New Thread".into())
+                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
         } else {
-            "New Thread".into()
+            DEFAULT_THREAD_TITLE.into()
         };
 
         let workspace = workspace.clone();
@@ -5122,7 +5126,7 @@ mod tests {
 
         let connection_b2 = StubAgentConnection::new();
         connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
-            acp::ContentChunk::new("New thread".into()),
+            acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()),
         )]);
         open_thread_with_connection(&panel_b, connection_b2, cx);
         send_message(&panel_b, cx);