From a0ba509838a47ecce970a0110221ab277adf6293 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 5 Mar 2026 22:45:50 -0800 Subject: [PATCH] Fix provisional thread title (#50905) Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 92 ++++++++++++++++++- .../src/connection_view/thread_view.rs | 7 +- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 1b9271918884dc020986577926d9578e3a6f049c..bffddde099c05438bb81c8bbbe99e3c77a5113e6 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -954,6 +954,7 @@ struct RunningTurn { pub struct AcpThread { parent_session_id: Option, title: SharedString, + provisional_title: Option, entries: Vec, plan: Plan, project: Entity, @@ -1199,6 +1200,7 @@ impl AcpThread { entries: Default::default(), plan: Default::default(), title: title.into(), + provisional_title: None, project, running_turn: None, turn_id: 0, @@ -1253,7 +1255,9 @@ impl AcpThread { } pub fn title(&self) -> SharedString { - self.title.clone() + self.provisional_title + .clone() + .unwrap_or_else(|| self.title.clone()) } pub fn entries(&self) -> &[AgentThreadEntry] { @@ -1505,16 +1509,29 @@ impl AcpThread { } pub fn set_title(&mut self, title: SharedString, cx: &mut Context) -> Task> { + let had_provisional = self.provisional_title.take().is_some(); if title != self.title { self.title = 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); } + } else if had_provisional { + cx.emit(AcpThreadEvent::TitleUpdated); } Task::ready(Ok(())) } + /// Sets a provisional display title without propagating back to the + /// underlying agent connection. This is used for quick preview titles + /// (e.g. first 20 chars of the user message) that should be shown + /// immediately but replaced once the LLM generates a proper title via + /// `set_title`. + pub fn set_provisional_title(&mut self, title: SharedString, cx: &mut Context) { + self.provisional_title = Some(title); + cx.emit(AcpThreadEvent::TitleUpdated); + } + pub fn subagent_spawned(&mut self, session_id: acp::SessionId, cx: &mut Context) { cx.emit(AcpThreadEvent::SubagentSpawned(session_id)); } @@ -3916,6 +3933,7 @@ mod tests { struct FakeAgentConnection { auth_methods: Vec, sessions: Arc>>>, + set_title_calls: Rc>>, on_user_message: Option< Rc< dyn Fn( @@ -3934,6 +3952,7 @@ mod tests { auth_methods: Vec::new(), on_user_message: None, sessions: Arc::default(), + set_title_calls: Default::default(), } } @@ -4038,11 +4057,32 @@ mod tests { })) } + fn set_title( + &self, + _session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + Some(Rc::new(FakeAgentSessionSetTitle { + calls: self.set_title_calls.clone(), + })) + } + fn into_any(self: Rc) -> Rc { self } } + struct FakeAgentSessionSetTitle { + calls: Rc>>, + } + + impl AgentSessionSetTitle for FakeAgentSessionSetTitle { + fn run(&self, title: SharedString, _cx: &mut App) -> Task> { + self.calls.borrow_mut().push(title); + Task::ready(Ok(())) + } + } + struct FakeAgentSessionEditor { _session_id: acp::SessionId, } @@ -4634,4 +4674,54 @@ mod tests { ); }); } + + #[gpui::test] + async fn test_provisional_title_replaced_by_real_title(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let set_title_calls = connection.set_title_calls.clone(); + + let thread = cx + .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .await + .unwrap(); + + // Initial title is the default. + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title().as_ref(), "Test"); + }); + + // Setting a provisional title updates the display title. + thread.update(cx, |thread, cx| { + 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…"); + }); + + // The provisional title should NOT have propagated to the connection. + assert_eq!( + set_title_calls.borrow().len(), + 0, + "provisional title should not propagate to the connection" + ); + + // When the real title arrives via set_title, it replaces the + // provisional title and propagates to the connection. + let task = thread.update(cx, |thread, cx| { + thread.set_title("Helping with Rust question".into(), cx) + }); + task.await.expect("set_title should succeed"); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title().as_ref(), "Helping with Rust question"); + }); + assert_eq!( + set_title_calls.borrow().as_slice(), + &[SharedString::from("Helping with Rust question")], + "real title should propagate to the connection" + ); + } } diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 64a0f61345b1a48dcfec5229d5e699fed8fee2bd..32c9f29cd6b9e60b498974cd7230a4f18a4b0f8e 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -990,10 +990,9 @@ impl ThreadView { let text = text.lines().next().unwrap_or("").trim(); if !text.is_empty() { let title: SharedString = util::truncate_and_trailoff(text, 20).into(); - thread - .update(cx, |thread, cx| thread.set_title(title, cx))? - .await - .log_err(); + thread.update(cx, |thread, cx| { + thread.set_provisional_title(title, cx); + })?; } }