From 731ae35b3515fe88fdd1d5aee1bdb6d6f8fb495b Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:42:42 +0000 Subject: [PATCH] When thread title generation fails, display error and (#54130) (cherry-pick to preview) (#54134) Cherry-pick of #54130 to preview ---- Release Notes: - Fixed an issue where thread titles remained in the generating state if the generation failed. Co-authored-by: Max Brunsfeld --- crates/agent/src/tests/mod.rs | 60 ++++++++++++++++++++++++++++++ crates/agent/src/thread.rs | 31 +++++++++------ crates/agent_ui/src/agent_panel.rs | 53 ++++++++++++++++++++------ crates/sidebar/src/sidebar.rs | 5 ++- 4 files changed, 124 insertions(+), 25 deletions(-) diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 2f4267b35616fafbf0a9b4cd2f4eecb2cfc1aec4..16952e178aff86a2af4e04dfb58b05c2391b3b5c 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -3160,6 +3160,66 @@ async fn test_title_generation(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_title_generation_failure_allows_retry(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let summary_model = Arc::new(FakeLanguageModel::default()); + let fake_summary_model = summary_model.as_fake(); + thread.update(cx, |thread, cx| { + thread.set_summarization_model(Some(summary_model.clone()), cx) + }); + + let send = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Hey!"); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + fake_summary_model.send_last_completion_stream_error( + LanguageModelCompletionError::UpstreamProviderError { + message: "Internal server error".to_string(), + status: gpui::http_client::StatusCode::INTERNAL_SERVER_ERROR, + retry_after: None, + }, + ); + fake_summary_model.end_last_completion_stream(); + send.collect::>().await; + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), None); + assert!(thread.has_failed_title_generation()); + assert!(!thread.is_generating_title()); + }); + + thread.update(cx, |thread, cx| { + thread.generate_title(cx); + }); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert!(!thread.has_failed_title_generation()); + assert!(thread.is_generating_title()); + }); + + fake_summary_model.send_last_completion_stream_text_chunk("Retried title"); + fake_summary_model.end_last_completion_stream(); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), Some("Retried title".into())); + assert!(!thread.has_failed_title_generation()); + assert!(!thread.is_generating_title()); + }); +} + #[gpui::test] async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index bd9ef285169bf98ce196990156a269e830ccd738..432c8c74a143e13d19eaaf4462136e4db57843f3 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -939,6 +939,7 @@ pub struct Thread { updated_at: DateTime, title: Option, pending_title_generation: Option>, + title_generation_failed: bool, pending_summary_generation: Option>>>, summary: Option, messages: Vec, @@ -1065,6 +1066,7 @@ impl Thread { updated_at: Utc::now(), title: None, pending_title_generation: None, + title_generation_failed: false, pending_summary_generation: None, summary: None, messages: Vec::new(), @@ -1298,6 +1300,7 @@ impl Thread { Some(db_thread.title.clone()) }, pending_title_generation: None, + title_generation_failed: false, pending_summary_generation: None, summary: db_thread.detailed_summary, messages: db_thread.messages, @@ -2556,6 +2559,10 @@ impl Thread { self.pending_title_generation.is_some() } + pub fn has_failed_title_generation(&self) -> bool { + self.title_generation_failed + } + pub fn summary(&mut self, cx: &mut Context) -> Shared>> { if let Some(summary) = self.summary.as_ref() { return Task::ready(Some(summary.clone())).shared(); @@ -2617,6 +2624,7 @@ impl Thread { } pub fn generate_title(&mut self, cx: &mut Context) { + self.title_generation_failed = false; let Some(model) = self.summarization_model.clone() else { return; }; @@ -2664,28 +2672,27 @@ impl Thread { anyhow::Ok(()) }; - if generate + let succeeded = generate .await .context("failed to generate thread title") .log_err() - .is_some() - { - _ = this.update(cx, |this, cx| this.set_title(title.into(), cx)); - } else { - // Emit TitleUpdated even on failure so that the propagation - // chain (agent::Thread → NativeAgent → AcpThread) fires and - // clears any provisional title that was set before the turn. - _ = this.update(cx, |_, cx| { + .is_some(); + _ = this.update(cx, |this, cx| { + this.pending_title_generation = None; + if succeeded { + this.set_title(title.into(), cx); + } else { + this.title_generation_failed = true; cx.emit(TitleUpdated); cx.notify(); - }); - } - _ = this.update(cx, |this, _| this.pending_title_generation = None); + } + }); })); } pub fn set_title(&mut self, title: SharedString, cx: &mut Context) { self.pending_title_generation = None; + self.title_generation_failed = false; if Some(&title) != self.title.as_ref() { self.title = Some(title); cx.emit(TitleUpdated); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 005476834089ad095aab5784fca6881f6124d9ba..88375dc7d6c4c4f4bf291b2a8d5d7d8dae602e41 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -80,8 +80,8 @@ use terminal::terminal_settings::TerminalSettings; use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use theme_settings::ThemeSettings; use ui::{ - Button, Callout, ContextMenu, ContextMenuEntry, PopoverMenu, PopoverMenuHandle, Tab, Tooltip, - prelude::*, utils::WithRemSize, + Button, Callout, ContextMenu, ContextMenuEntry, IconButton, PopoverMenu, PopoverMenuHandle, + Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::{ResultExt as _, debug_panic}; use workspace::{ @@ -3869,10 +3869,13 @@ impl AgentPanel { let content = match self.visible_surface() { VisibleSurface::AgentThread(conversation_view) => { let server_view_ref = conversation_view.read(cx); - let is_generating_title = server_view_ref.as_native_thread(cx).is_some() - && server_view_ref.root_thread_view().map_or(false, |tv| { - tv.read(cx).thread.read(cx).has_provisional_title() - }); + let native_thread = server_view_ref.as_native_thread(cx); + let is_generating_title = native_thread + .as_ref() + .is_some_and(|thread| thread.read(cx).is_generating_title()); + let title_generation_failed = native_thread + .as_ref() + .is_some_and(|thread| thread.read(cx).has_failed_title_generation()); if let Some(title_editor) = server_view_ref .root_thread_view() @@ -3891,8 +3894,8 @@ impl AgentPanel { ) .into_any_element() } else { - div() - .w_full() + let editable_title = div() + .flex_1() .on_action({ let conversation_view = conversation_view.downgrade(); move |_: &menu::Confirm, window, cx| { @@ -3909,8 +3912,33 @@ impl AgentPanel { } } }) - .child(title_editor) - .into_any_element() + .child(title_editor); + + if title_generation_failed { + h_flex() + .w_full() + .gap_1() + .items_center() + .child(editable_title) + .child( + IconButton::new("retry-thread-title", IconName::XCircle) + .icon_color(Color::Error) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Title generation failed. Retry")) + .on_click({ + let conversation_view = conversation_view.clone(); + move |_event, _window, cx| { + Self::handle_regenerate_thread_title( + conversation_view.clone(), + cx, + ); + } + }), + ) + .into_any_element() + } else { + editable_title.w_full().into_any_element() + } } } else { Label::new(conversation_view.read(cx).title(cx)) @@ -3941,7 +3969,10 @@ impl AgentPanel { conversation_view.update(cx, |conversation_view, cx| { if let Some(thread) = conversation_view.as_native_thread(cx) { thread.update(cx, |thread, cx| { - thread.generate_title(cx); + if !thread.is_generating_title() { + thread.generate_title(cx); + cx.notify(); + } }); } }); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 381da123c80ff30987ab9bd2f01207b06184d8f2..1b8fd6a224bf1d1ac558e68f15ba8516db495493 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -4909,8 +4909,9 @@ fn all_thread_infos_for_workspace( 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 is_title_generating = thread_view_ref + .as_native_thread(cx) + .is_some_and(|native_thread| native_thread.read(cx).is_generating_title()); let session_id = thread.session_id().clone(); let is_background = agent_panel.is_retained_thread(&conversation_thread_id);