agent: Bring title editing back to text threads (#29425)

Danilo Leal and Michael Sloan created

This also fixes a little UI bug where the text thread title would push
the buttons away from the UI when there was still space.

Release Notes:

- agent: Made text thread titles editable again.

---------

Co-authored-by: Michael Sloan <mgsloan@gmail.com>

Change summary

crates/agent/src/assistant_panel.rs                   | 134 +++++++++++-
crates/assistant/src/assistant_panel.rs               |   2 
crates/assistant_context_editor/src/context.rs        |  42 +++
crates/assistant_context_editor/src/context_editor.rs |  12 
4 files changed, 157 insertions(+), 33 deletions(-)

Detailed changes

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

@@ -5,8 +5,9 @@ use std::time::Duration;
 
 use anyhow::{Result, anyhow};
 use assistant_context_editor::{
-    AssistantPanelDelegate, ConfigurationError, ContextEditor, SlashCommandCompletionProvider,
-    humanize_token_count, make_lsp_adapter_delegate, render_remaining_tokens,
+    AssistantContext, AssistantPanelDelegate, ConfigurationError, ContextEditor, ContextEvent,
+    SlashCommandCompletionProvider, humanize_token_count, make_lsp_adapter_delegate,
+    render_remaining_tokens,
 };
 use assistant_settings::{AssistantDockPosition, AssistantSettings};
 use assistant_slash_command::SlashCommandWorkingSet;
@@ -116,6 +117,8 @@ enum ActiveView {
     },
     PromptEditor {
         context_editor: Entity<ContextEditor>,
+        title_editor: Entity<Editor>,
+        _subscriptions: Vec<gpui::Subscription>,
     },
     History,
     Configuration,
@@ -176,6 +179,83 @@ impl ActiveView {
             _subscriptions: subscriptions,
         }
     }
+
+    pub fn prompt_editor(
+        context_editor: Entity<ContextEditor>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Self {
+        let title = context_editor.read(cx).title(cx).to_string();
+
+        let editor = cx.new(|cx| {
+            let mut editor = Editor::single_line(window, cx);
+            editor.set_text(title, window, cx);
+            editor
+        });
+
+        // This is a workaround for `editor.set_text` emitting a `BufferEdited` event, which would
+        // cause a custom summary to be set. The presence of this custom summary would cause
+        // summarization to not happen.
+        let mut suppress_first_edit = true;
+
+        let subscriptions = vec![
+            window.subscribe(&editor, cx, {
+                {
+                    let context_editor = context_editor.clone();
+                    move |editor, event, window, cx| match event {
+                        EditorEvent::BufferEdited => {
+                            if suppress_first_edit {
+                                suppress_first_edit = false;
+                                return;
+                            }
+                            let new_summary = editor.read(cx).text(cx);
+
+                            context_editor.update(cx, |context_editor, cx| {
+                                context_editor
+                                    .context()
+                                    .update(cx, |assistant_context, cx| {
+                                        assistant_context.set_custom_summary(new_summary, cx);
+                                    })
+                            })
+                        }
+                        EditorEvent::Blurred => {
+                            if editor.read(cx).text(cx).is_empty() {
+                                let summary = context_editor
+                                    .read(cx)
+                                    .context()
+                                    .read(cx)
+                                    .summary_or_default();
+
+                                editor.update(cx, |editor, cx| {
+                                    editor.set_text(summary, window, cx);
+                                });
+                            }
+                        }
+                        _ => {}
+                    }
+                }
+            }),
+            window.subscribe(&context_editor.read(cx).context().clone(), cx, {
+                let editor = editor.clone();
+                move |assistant_context, event, window, cx| match event {
+                    ContextEvent::SummaryGenerated => {
+                        let summary = assistant_context.read(cx).summary_or_default();
+
+                        editor.update(cx, |editor, cx| {
+                            editor.set_text(summary, window, cx);
+                        })
+                    }
+                    _ => {}
+                }
+            }),
+        ];
+
+        Self::PromptEditor {
+            context_editor,
+            title_editor: editor,
+            _subscriptions: subscriptions,
+        }
+    }
 }
 
 pub struct AssistantPanel {
@@ -502,9 +582,7 @@ impl AssistantPanel {
         });
 
         self.set_active_view(
-            ActiveView::PromptEditor {
-                context_editor: context_editor.clone(),
-            },
+            ActiveView::prompt_editor(context_editor.clone(), window, cx),
             window,
             cx,
         );
@@ -578,10 +656,9 @@ impl AssistantPanel {
                         cx,
                     )
                 });
+
                 this.set_active_view(
-                    ActiveView::PromptEditor {
-                        context_editor: editor,
-                    },
+                    ActiveView::prompt_editor(editor.clone(), window, cx),
                     window,
                     cx,
                 );
@@ -821,7 +898,7 @@ impl AssistantPanel {
 
     pub(crate) fn active_context_editor(&self) -> Option<Entity<ContextEditor>> {
         match &self.active_view {
-            ActiveView::PromptEditor { context_editor } => Some(context_editor.clone()),
+            ActiveView::PromptEditor { context_editor, .. } => Some(context_editor.clone()),
             _ => None,
         }
     }
@@ -864,7 +941,7 @@ impl Focusable for AssistantPanel {
         match &self.active_view {
             ActiveView::Thread { .. } => self.message_editor.focus_handle(cx),
             ActiveView::History => self.history.focus_handle(cx),
-            ActiveView::PromptEditor { context_editor } => context_editor.focus_handle(cx),
+            ActiveView::PromptEditor { context_editor, .. } => context_editor.focus_handle(cx),
             ActiveView::Configuration => {
                 if let Some(configuration) = self.configuration.as_ref() {
                     configuration.focus_handle(cx)
@@ -988,9 +1065,34 @@ impl AssistantPanel {
                         .into_any_element()
                 }
             }
-            ActiveView::PromptEditor { context_editor } => {
-                let title = SharedString::from(context_editor.read(cx).title(cx).to_string());
-                Label::new(title).ml_2().truncate().into_any_element()
+            ActiveView::PromptEditor {
+                title_editor,
+                context_editor,
+                ..
+            } => {
+                let context_editor = context_editor.read(cx);
+                let summary = context_editor.context().read(cx).summary();
+
+                match summary {
+                    None => Label::new(AssistantContext::DEFAULT_SUMMARY.clone())
+                        .truncate()
+                        .ml_2()
+                        .into_any_element(),
+                    Some(summary) => {
+                        if summary.done {
+                            div()
+                                .ml_2()
+                                .w_full()
+                                .child(title_editor.clone())
+                                .into_any_element()
+                        } else {
+                            Label::new(LOADING_SUMMARY_PLACEHOLDER)
+                                .ml_2()
+                                .truncate()
+                                .into_any_element()
+                        }
+                    }
+                }
             }
             ActiveView::History => Label::new("History").truncate().into_any_element(),
             ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
@@ -1263,7 +1365,7 @@ impl AssistantPanel {
 
                 Some(token_count)
             }
-            ActiveView::PromptEditor { context_editor } => {
+            ActiveView::PromptEditor { context_editor, .. } => {
                 let element = render_remaining_tokens(context_editor, cx)?;
 
                 Some(element.into_any_element())
@@ -1871,7 +1973,9 @@ impl Render for AssistantPanel {
                     .child(h_flex().child(self.message_editor.clone()))
                     .children(self.render_last_error(cx)),
                 ActiveView::History => parent.child(self.history.clone()),
-                ActiveView::PromptEditor { context_editor } => parent.child(context_editor.clone()),
+                ActiveView::PromptEditor { context_editor, .. } => {
+                    parent.child(context_editor.clone())
+                }
                 ActiveView::Configuration => parent.children(self.configuration.clone()),
             })
     }

crates/assistant_context_editor/src/context.rs πŸ”—

@@ -459,6 +459,7 @@ pub enum ContextEvent {
     ShowMaxMonthlySpendReachedError,
     MessagesEdited,
     SummaryChanged,
+    SummaryGenerated,
     StreamedCompletion,
     StartedThoughtProcess(Range<language::Anchor>),
     EndedThoughtProcess(language::Anchor),
@@ -482,7 +483,7 @@ pub enum ContextEvent {
 #[derive(Clone, Default, Debug)]
 pub struct ContextSummary {
     pub text: String,
-    done: bool,
+    pub done: bool,
     timestamp: clock::Lamport,
 }
 
@@ -640,7 +641,7 @@ pub struct AssistantContext {
     contents: Vec<Content>,
     messages_metadata: HashMap<MessageId, MessageMetadata>,
     summary: Option<ContextSummary>,
-    pending_summary: Task<Option<()>>,
+    summary_task: Task<Option<()>>,
     completion_count: usize,
     pending_completions: Vec<PendingCompletion>,
     token_count: Option<usize>,
@@ -741,7 +742,7 @@ impl AssistantContext {
             thought_process_output_sections: Vec::new(),
             edits_since_last_parse: edits_since_last_slash_command_parse,
             summary: None,
-            pending_summary: Task::ready(None),
+            summary_task: Task::ready(None),
             completion_count: Default::default(),
             pending_completions: Default::default(),
             token_count: None,
@@ -951,7 +952,7 @@ impl AssistantContext {
 
     fn flush_ops(&mut self, cx: &mut Context<AssistantContext>) {
         let mut changed_messages = HashSet::default();
-        let mut summary_changed = false;
+        let mut summary_generated = false;
 
         self.pending_ops.sort_unstable_by_key(|op| op.timestamp());
         for op in mem::take(&mut self.pending_ops) {
@@ -993,7 +994,7 @@ impl AssistantContext {
                         .map_or(true, |summary| new_summary.timestamp > summary.timestamp)
                     {
                         self.summary = Some(new_summary);
-                        summary_changed = true;
+                        summary_generated = true;
                     }
                 }
                 ContextOperation::SlashCommandStarted {
@@ -1072,8 +1073,9 @@ impl AssistantContext {
             cx.notify();
         }
 
-        if summary_changed {
+        if summary_generated {
             cx.emit(ContextEvent::SummaryChanged);
+            cx.emit(ContextEvent::SummaryGenerated);
             cx.notify();
         }
     }
@@ -2947,7 +2949,7 @@ impl AssistantContext {
         self.message_anchors.insert(insertion_ix, new_anchor);
     }
 
-    pub fn summarize(&mut self, replace_old: bool, cx: &mut Context<Self>) {
+    pub fn summarize(&mut self, mut replace_old: bool, cx: &mut Context<Self>) {
         let Some(model) = LanguageModelRegistry::read_global(cx).default_model() else {
             return;
         };
@@ -2967,7 +2969,18 @@ impl AssistantContext {
                 cache: false,
             });
 
-            self.pending_summary = cx.spawn(async move |this, cx| {
+            // If there is no summary, it is set with `done: false` so that "Loading Summary…" can
+            // be displayed.
+            if self.summary.is_none() {
+                self.summary = Some(ContextSummary {
+                    text: "".to_string(),
+                    done: false,
+                    timestamp: clock::Lamport::default(),
+                });
+                replace_old = true;
+            }
+
+            self.summary_task = cx.spawn(async move |this, cx| {
                 async move {
                     let stream = model.model.stream_completion_text(request, &cx);
                     let mut messages = stream.await?;
@@ -2992,6 +3005,7 @@ impl AssistantContext {
                             };
                             this.push_op(operation, cx);
                             cx.emit(ContextEvent::SummaryChanged);
+                            cx.emit(ContextEvent::SummaryGenerated);
                         })?;
 
                         // Stop if the LLM generated multiple lines.
@@ -3012,6 +3026,7 @@ impl AssistantContext {
                             };
                             this.push_op(operation, cx);
                             cx.emit(ContextEvent::SummaryChanged);
+                            cx.emit(ContextEvent::SummaryGenerated);
                         }
                     })?;
 
@@ -3184,7 +3199,7 @@ impl AssistantContext {
         });
     }
 
-    pub fn custom_summary(&mut self, custom_summary: String, cx: &mut Context<Self>) {
+    pub fn set_custom_summary(&mut self, custom_summary: String, cx: &mut Context<Self>) {
         let timestamp = self.next_timestamp();
         let summary = self.summary.get_or_insert(ContextSummary::default());
         summary.timestamp = timestamp;
@@ -3192,6 +3207,15 @@ impl AssistantContext {
         summary.text = custom_summary;
         cx.emit(ContextEvent::SummaryChanged);
     }
+
+    pub const DEFAULT_SUMMARY: SharedString = SharedString::new_static("New Text Thread");
+
+    pub fn summary_or_default(&self) -> SharedString {
+        self.summary
+            .as_ref()
+            .map(|summary| summary.text.clone().into())
+            .unwrap_or(Self::DEFAULT_SUMMARY)
+    }
 }
 
 fn trimmed_text_in_range(buffer: &BufferSnapshot, range: Range<text::Anchor>) -> String {

crates/assistant_context_editor/src/context_editor.rs πŸ”—

@@ -48,7 +48,7 @@ use project::{Project, Worktree};
 use rope::Point;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore, update_settings_file};
-use std::{any::TypeId, borrow::Cow, cmp, ops::Range, path::PathBuf, sync::Arc, time::Duration};
+use std::{any::TypeId, cmp, ops::Range, path::PathBuf, sync::Arc, time::Duration};
 use text::SelectionGoal;
 use ui::{
     ButtonLike, Disclosure, ElevationIndex, KeyBinding, PopoverMenuHandle, TintColor, Tooltip,
@@ -618,6 +618,7 @@ impl ContextEditor {
                     context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx);
                 });
             }
+            ContextEvent::SummaryGenerated => {}
             ContextEvent::StartedThoughtProcess(range) => {
                 let creases = self.insert_thought_process_output_sections(
                     [(
@@ -2179,13 +2180,8 @@ impl ContextEditor {
         });
     }
 
-    pub fn title(&self, cx: &App) -> Cow<str> {
-        self.context
-            .read(cx)
-            .summary()
-            .map(|summary| summary.text.clone())
-            .map(Cow::Owned)
-            .unwrap_or_else(|| Cow::Borrowed(DEFAULT_TAB_TITLE))
+    pub fn title(&self, cx: &App) -> SharedString {
+        self.context.read(cx).summary_or_default()
     }
 
     fn render_patch_block(