agent: Allow renaming threads (#28102)

Agus Zubiaga , Danilo Leal , Bennet Bo Fenner , and Richard Feldman created

Release Notes:

- agent: Add support for renaming threads

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>

Change summary

crates/agent/src/active_thread.rs   |   4 
crates/agent/src/agent_diff.rs      |   2 
crates/agent/src/assistant_panel.rs | 174 +++++++++++++++++++++++-------
crates/agent/src/thread.rs          |  22 +++
4 files changed, 151 insertions(+), 51 deletions(-)

Detailed changes

crates/agent/src/active_thread.rs 🔗

@@ -512,7 +512,9 @@ impl ActiveThread {
             ThreadEvent::ShowError(error) => {
                 self.last_error = Some(error.clone());
             }
-            ThreadEvent::StreamedCompletion | ThreadEvent::SummaryChanged => {
+            ThreadEvent::StreamedCompletion
+            | ThreadEvent::SummaryGenerated
+            | ThreadEvent::SummaryChanged => {
                 self.save_thread(cx);
             }
             ThreadEvent::DoneStreaming => {

crates/agent/src/agent_diff.rs 🔗

@@ -238,7 +238,7 @@ impl AgentDiff {
 
     fn handle_thread_event(&mut self, event: &ThreadEvent, cx: &mut Context<Self>) {
         match event {
-            ThreadEvent::SummaryChanged => self.update_title(cx),
+            ThreadEvent::SummaryGenerated => self.update_title(cx),
             _ => {}
         }
     }

crates/agent/src/assistant_panel.rs 🔗

@@ -12,7 +12,7 @@ use assistant_slash_command::SlashCommandWorkingSet;
 use assistant_tool::ToolWorkingSet;
 
 use client::zed_urls;
-use editor::{Editor, MultiBuffer};
+use editor::{Editor, EditorEvent, MultiBuffer};
 use fs::Fs;
 use gpui::{
     Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, Corner, Entity,
@@ -44,7 +44,7 @@ use crate::thread_history::{PastContext, PastThread, ThreadHistory};
 use crate::thread_store::ThreadStore;
 use crate::{
     AgentDiff, InlineAssistant, NewPromptEditor, NewThread, OpenActiveThreadAsMarkdown,
-    OpenAgentDiff, OpenConfiguration, OpenHistory, ToggleContextPicker,
+    OpenAgentDiff, OpenConfiguration, OpenHistory, ThreadEvent, ToggleContextPicker,
 };
 
 action_with_deprecated_aliases!(
@@ -103,12 +103,72 @@ pub fn init(cx: &mut App) {
 }
 
 enum ActiveView {
-    Thread,
+    Thread {
+        change_title_editor: Entity<Editor>,
+        _subscriptions: Vec<gpui::Subscription>,
+    },
     PromptEditor,
     History,
     Configuration,
 }
 
+impl ActiveView {
+    pub fn thread(thread: Entity<Thread>, window: &mut Window, cx: &mut App) -> Self {
+        let summary = thread.read(cx).summary_or_default();
+
+        let editor = cx.new(|cx| {
+            let mut editor = Editor::single_line(window, cx);
+            editor.set_text(summary, window, cx);
+            editor
+        });
+
+        let subscriptions = vec![
+            window.subscribe(&editor, cx, {
+                {
+                    let thread = thread.clone();
+                    move |editor, event, window, cx| match event {
+                        EditorEvent::BufferEdited => {
+                            let new_summary = editor.read(cx).text(cx);
+
+                            thread.update(cx, |thread, cx| {
+                                thread.set_summary(new_summary, cx);
+                            })
+                        }
+                        EditorEvent::Blurred => {
+                            if editor.read(cx).text(cx).is_empty() {
+                                let summary = thread.read(cx).summary_or_default();
+
+                                editor.update(cx, |editor, cx| {
+                                    editor.set_text(summary, window, cx);
+                                });
+                            }
+                        }
+                        _ => {}
+                    }
+                }
+            }),
+            window.subscribe(&thread, cx, {
+                let editor = editor.clone();
+                move |thread, event, window, cx| match event {
+                    ThreadEvent::SummaryGenerated => {
+                        let summary = thread.read(cx).summary_or_default();
+
+                        editor.update(cx, |editor, cx| {
+                            editor.set_text(summary, window, cx);
+                        })
+                    }
+                    _ => {}
+                }
+            }),
+        ];
+
+        Self::Thread {
+            change_title_editor: editor,
+            _subscriptions: subscriptions,
+        }
+    }
+}
+
 pub struct AssistantPanel {
     workspace: WeakEntity<Workspace>,
     project: Entity<Project>,
@@ -198,6 +258,7 @@ impl AssistantPanel {
         let history_store =
             cx.new(|cx| HistoryStore::new(thread_store.clone(), context_store.clone(), cx));
 
+        let active_view = ActiveView::thread(thread.clone(), window, cx);
         let thread = cx.new(|cx| {
             ActiveThread::new(
                 thread.clone(),
@@ -211,7 +272,7 @@ impl AssistantPanel {
         });
 
         Self {
-            active_view: ActiveView::Thread,
+            active_view,
             workspace,
             project: project.clone(),
             fs: fs.clone(),
@@ -272,7 +333,7 @@ impl AssistantPanel {
             .thread_store
             .update(cx, |this, cx| this.create_thread(cx));
 
-        self.active_view = ActiveView::Thread;
+        self.active_view = ActiveView::thread(thread.clone(), window, cx);
 
         let message_editor_context_store = cx.new(|_cx| {
             crate::context_store::ContextStore::new(
@@ -436,7 +497,7 @@ impl AssistantPanel {
         cx.spawn_in(window, async move |this, cx| {
             let thread = open_thread_task.await?;
             this.update_in(cx, |this, window, cx| {
-                this.active_view = ActiveView::Thread;
+                this.active_view = ActiveView::thread(thread.clone(), window, cx);
                 let message_editor_context_store = cx.new(|_cx| {
                     crate::context_store::ContextStore::new(
                         this.workspace.clone(),
@@ -612,7 +673,7 @@ impl AssistantPanel {
 impl Focusable for AssistantPanel {
     fn focus_handle(&self, cx: &App) -> FocusHandle {
         match self.active_view {
-            ActiveView::Thread => self.message_editor.focus_handle(cx),
+            ActiveView::Thread { .. } => self.message_editor.focus_handle(cx),
             ActiveView::History => self.history.focus_handle(cx),
             ActiveView::PromptEditor => {
                 if let Some(context_editor) = self.context_editor.as_ref() {
@@ -713,7 +774,59 @@ impl Panel for AssistantPanel {
 }
 
 impl AssistantPanel {
-    fn render_toolbar(&self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render_title_view(&self, _window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
+        const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…";
+
+        let content = match &self.active_view {
+            ActiveView::Thread {
+                change_title_editor,
+                ..
+            } => {
+                let active_thread = self.thread.read(cx);
+                let is_empty = active_thread.is_empty();
+
+                let summary = active_thread.summary(cx);
+
+                if is_empty {
+                    Label::new(Thread::DEFAULT_SUMMARY.clone())
+                        .truncate()
+                        .into_any_element()
+                } else if summary.is_none() {
+                    Label::new(LOADING_SUMMARY_PLACEHOLDER)
+                        .truncate()
+                        .into_any_element()
+                } else {
+                    change_title_editor.clone().into_any_element()
+                }
+            }
+            ActiveView::PromptEditor => {
+                let title = self
+                    .context_editor
+                    .as_ref()
+                    .map(|context_editor| {
+                        SharedString::from(context_editor.read(cx).title(cx).to_string())
+                    })
+                    .unwrap_or_else(|| SharedString::from(LOADING_SUMMARY_PLACEHOLDER));
+
+                Label::new(title).truncate().into_any_element()
+            }
+            ActiveView::History => Label::new("History").truncate().into_any_element(),
+            ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
+        };
+
+        h_flex()
+            .key_context("TitleEditor")
+            .id("TitleEditor")
+            .pl_2()
+            .flex_grow()
+            .w_full()
+            .max_w_full()
+            .overflow_x_scroll()
+            .child(content)
+            .into_any()
+    }
+
+    fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let active_thread = self.thread.read(cx);
         let thread = active_thread.thread().read(cx);
         let token_usage = thread.total_token_usage(cx);
@@ -723,29 +836,8 @@ impl AssistantPanel {
         let is_empty = active_thread.is_empty();
         let focus_handle = self.focus_handle(cx);
 
-        let title = match self.active_view {
-            ActiveView::Thread => {
-                if is_empty {
-                    active_thread.summary_or_default(cx)
-                } else {
-                    active_thread
-                        .summary(cx)
-                        .unwrap_or_else(|| SharedString::from("Loading Summary…"))
-                }
-            }
-            ActiveView::PromptEditor => self
-                .context_editor
-                .as_ref()
-                .map(|context_editor| {
-                    SharedString::from(context_editor.read(cx).title(cx).to_string())
-                })
-                .unwrap_or_else(|| SharedString::from("Loading Summary…")),
-            ActiveView::History => "History".into(),
-            ActiveView::Configuration => "Settings".into(),
-        };
-
-        let show_token_count = match self.active_view {
-            ActiveView::Thread => !is_empty,
+        let show_token_count = match &self.active_view {
+            ActiveView::Thread { .. } => !is_empty,
             ActiveView::PromptEditor => self.context_editor.is_some(),
             _ => false,
         };
@@ -753,27 +845,20 @@ impl AssistantPanel {
         h_flex()
             .id("assistant-toolbar")
             .h(Tab::container_height(cx))
+            .max_w_full()
             .flex_none()
             .justify_between()
-            .gap(DynamicSpacing::Base08.rems(cx))
+            .gap_2()
             .bg(cx.theme().colors().tab_bar_background)
             .border_b_1()
             .border_color(cx.theme().colors().border)
-            .child(
-                div()
-                    .id("title")
-                    .overflow_x_scroll()
-                    .px(DynamicSpacing::Base08.rems(cx))
-                    .child(Label::new(title).truncate()),
-            )
+            .child(self.render_title_view(window, cx))
             .child(
                 h_flex()
                     .h_full()
-                    .pl_2()
                     .gap_2()
-                    .bg(cx.theme().colors().tab_bar_background)
                     .when(show_token_count, |parent| match self.active_view {
-                        ActiveView::Thread => {
+                        ActiveView::Thread { .. } => {
                             if token_usage.total == 0 {
                                 return parent;
                             }
@@ -786,6 +871,7 @@ impl AssistantPanel {
 
                             parent.child(
                                 h_flex()
+                                    .flex_shrink_0()
                                     .gap_0p5()
                                     .child(
                                         Label::new(assistant_context_editor::humanize_token_count(
@@ -837,10 +923,10 @@ impl AssistantPanel {
                     .child(
                         h_flex()
                             .h_full()
+                            .gap(DynamicSpacing::Base02.rems(cx))
                             .px(DynamicSpacing::Base08.rems(cx))
                             .border_l_1()
                             .border_color(cx.theme().colors().border)
-                            .gap(DynamicSpacing::Base02.rems(cx))
                             .child(
                                 IconButton::new("new", IconName::Plus)
                                     .icon_size(IconSize::Small)
@@ -1394,7 +1480,7 @@ impl Render for AssistantPanel {
             .on_action(cx.listener(Self::open_agent_diff))
             .child(self.render_toolbar(window, cx))
             .map(|parent| match self.active_view {
-                ActiveView::Thread => parent
+                ActiveView::Thread { .. } => parent
                     .child(self.render_active_thread_or_empty_state(window, cx))
                     .child(h_flex().child(self.message_editor.clone()))
                     .children(self.render_last_error(cx)),

crates/agent/src/thread.rs 🔗

@@ -385,14 +385,25 @@ impl Thread {
         self.summary.clone()
     }
 
+    pub const DEFAULT_SUMMARY: SharedString = SharedString::new_static("New Thread");
+
     pub fn summary_or_default(&self) -> SharedString {
-        const DEFAULT: SharedString = SharedString::new_static("New Thread");
-        self.summary.clone().unwrap_or(DEFAULT)
+        self.summary.clone().unwrap_or(Self::DEFAULT_SUMMARY)
     }
 
     pub fn set_summary(&mut self, summary: impl Into<SharedString>, cx: &mut Context<Self>) {
-        self.summary = Some(summary.into());
-        cx.emit(ThreadEvent::SummaryChanged);
+        let summary = summary.into();
+        let old_summary = self.summary_or_default();
+
+        self.summary = if summary.is_empty() {
+            Some(Self::DEFAULT_SUMMARY)
+        } else {
+            Some(summary)
+        };
+
+        if Some(old_summary) != self.summary {
+            cx.emit(ThreadEvent::SummaryChanged);
+        }
     }
 
     pub fn latest_detailed_summary_or_text(&self) -> SharedString {
@@ -1293,7 +1304,7 @@ impl Thread {
                         this.summary = Some(new_summary.into());
                     }
 
-                    cx.emit(ThreadEvent::SummaryChanged);
+                    cx.emit(ThreadEvent::SummaryGenerated);
                 })?;
 
                 anyhow::Ok(())
@@ -1847,6 +1858,7 @@ pub enum ThreadEvent {
     MessageAdded(MessageId),
     MessageEdited(MessageId),
     MessageDeleted(MessageId),
+    SummaryGenerated,
     SummaryChanged,
     UsePendingTools,
     ToolFinished {