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

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>
This commit is contained in:
Danilo Leal 2025-04-28 09:09:19 -03:00 committed by GitHub
parent ddfeb202a3
commit f735c90c3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 157 additions and 33 deletions

View file

@ -5,8 +5,9 @@ use std::time::Duration;
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use assistant_context_editor::{ use assistant_context_editor::{
AssistantPanelDelegate, ConfigurationError, ContextEditor, SlashCommandCompletionProvider, AssistantContext, AssistantPanelDelegate, ConfigurationError, ContextEditor, ContextEvent,
humanize_token_count, make_lsp_adapter_delegate, render_remaining_tokens, SlashCommandCompletionProvider, humanize_token_count, make_lsp_adapter_delegate,
render_remaining_tokens,
}; };
use assistant_settings::{AssistantDockPosition, AssistantSettings}; use assistant_settings::{AssistantDockPosition, AssistantSettings};
use assistant_slash_command::SlashCommandWorkingSet; use assistant_slash_command::SlashCommandWorkingSet;
@ -116,6 +117,8 @@ enum ActiveView {
}, },
PromptEditor { PromptEditor {
context_editor: Entity<ContextEditor>, context_editor: Entity<ContextEditor>,
title_editor: Entity<Editor>,
_subscriptions: Vec<gpui::Subscription>,
}, },
History, History,
Configuration, Configuration,
@ -176,6 +179,83 @@ impl ActiveView {
_subscriptions: subscriptions, _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 { pub struct AssistantPanel {
@ -502,9 +582,7 @@ impl AssistantPanel {
}); });
self.set_active_view( self.set_active_view(
ActiveView::PromptEditor { ActiveView::prompt_editor(context_editor.clone(), window, cx),
context_editor: context_editor.clone(),
},
window, window,
cx, cx,
); );
@ -578,10 +656,9 @@ impl AssistantPanel {
cx, cx,
) )
}); });
this.set_active_view( this.set_active_view(
ActiveView::PromptEditor { ActiveView::prompt_editor(editor.clone(), window, cx),
context_editor: editor,
},
window, window,
cx, cx,
); );
@ -821,7 +898,7 @@ impl AssistantPanel {
pub(crate) fn active_context_editor(&self) -> Option<Entity<ContextEditor>> { pub(crate) fn active_context_editor(&self) -> Option<Entity<ContextEditor>> {
match &self.active_view { match &self.active_view {
ActiveView::PromptEditor { context_editor } => Some(context_editor.clone()), ActiveView::PromptEditor { context_editor, .. } => Some(context_editor.clone()),
_ => None, _ => None,
} }
} }
@ -864,7 +941,7 @@ impl Focusable for AssistantPanel {
match &self.active_view { 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::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 => { ActiveView::Configuration => {
if let Some(configuration) = self.configuration.as_ref() { if let Some(configuration) = self.configuration.as_ref() {
configuration.focus_handle(cx) configuration.focus_handle(cx)
@ -988,9 +1065,34 @@ impl AssistantPanel {
.into_any_element() .into_any_element()
} }
} }
ActiveView::PromptEditor { context_editor } => { ActiveView::PromptEditor {
let title = SharedString::from(context_editor.read(cx).title(cx).to_string()); title_editor,
Label::new(title).ml_2().truncate().into_any_element() 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::History => Label::new("History").truncate().into_any_element(),
ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(), ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
@ -1263,7 +1365,7 @@ impl AssistantPanel {
Some(token_count) Some(token_count)
} }
ActiveView::PromptEditor { context_editor } => { ActiveView::PromptEditor { context_editor, .. } => {
let element = render_remaining_tokens(context_editor, cx)?; let element = render_remaining_tokens(context_editor, cx)?;
Some(element.into_any_element()) Some(element.into_any_element())
@ -1871,7 +1973,9 @@ impl Render for AssistantPanel {
.child(h_flex().child(self.message_editor.clone())) .child(h_flex().child(self.message_editor.clone()))
.children(self.render_last_error(cx)), .children(self.render_last_error(cx)),
ActiveView::History => parent.child(self.history.clone()), 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()), ActiveView::Configuration => parent.children(self.configuration.clone()),
}) })
} }

View file

@ -476,7 +476,7 @@ impl AssistantPanel {
{ {
return; return;
} }
context.custom_summary(new_summary, cx) context.set_custom_summary(new_summary, cx)
}); });
}); });
} }

View file

@ -459,6 +459,7 @@ pub enum ContextEvent {
ShowMaxMonthlySpendReachedError, ShowMaxMonthlySpendReachedError,
MessagesEdited, MessagesEdited,
SummaryChanged, SummaryChanged,
SummaryGenerated,
StreamedCompletion, StreamedCompletion,
StartedThoughtProcess(Range<language::Anchor>), StartedThoughtProcess(Range<language::Anchor>),
EndedThoughtProcess(language::Anchor), EndedThoughtProcess(language::Anchor),
@ -482,7 +483,7 @@ pub enum ContextEvent {
#[derive(Clone, Default, Debug)] #[derive(Clone, Default, Debug)]
pub struct ContextSummary { pub struct ContextSummary {
pub text: String, pub text: String,
done: bool, pub done: bool,
timestamp: clock::Lamport, timestamp: clock::Lamport,
} }
@ -640,7 +641,7 @@ pub struct AssistantContext {
contents: Vec<Content>, contents: Vec<Content>,
messages_metadata: HashMap<MessageId, MessageMetadata>, messages_metadata: HashMap<MessageId, MessageMetadata>,
summary: Option<ContextSummary>, summary: Option<ContextSummary>,
pending_summary: Task<Option<()>>, summary_task: Task<Option<()>>,
completion_count: usize, completion_count: usize,
pending_completions: Vec<PendingCompletion>, pending_completions: Vec<PendingCompletion>,
token_count: Option<usize>, token_count: Option<usize>,
@ -741,7 +742,7 @@ impl AssistantContext {
thought_process_output_sections: Vec::new(), thought_process_output_sections: Vec::new(),
edits_since_last_parse: edits_since_last_slash_command_parse, edits_since_last_parse: edits_since_last_slash_command_parse,
summary: None, summary: None,
pending_summary: Task::ready(None), summary_task: Task::ready(None),
completion_count: Default::default(), completion_count: Default::default(),
pending_completions: Default::default(), pending_completions: Default::default(),
token_count: None, token_count: None,
@ -951,7 +952,7 @@ impl AssistantContext {
fn flush_ops(&mut self, cx: &mut Context<AssistantContext>) { fn flush_ops(&mut self, cx: &mut Context<AssistantContext>) {
let mut changed_messages = HashSet::default(); 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()); self.pending_ops.sort_unstable_by_key(|op| op.timestamp());
for op in mem::take(&mut self.pending_ops) { for op in mem::take(&mut self.pending_ops) {
@ -993,7 +994,7 @@ impl AssistantContext {
.map_or(true, |summary| new_summary.timestamp > summary.timestamp) .map_or(true, |summary| new_summary.timestamp > summary.timestamp)
{ {
self.summary = Some(new_summary); self.summary = Some(new_summary);
summary_changed = true; summary_generated = true;
} }
} }
ContextOperation::SlashCommandStarted { ContextOperation::SlashCommandStarted {
@ -1072,8 +1073,9 @@ impl AssistantContext {
cx.notify(); cx.notify();
} }
if summary_changed { if summary_generated {
cx.emit(ContextEvent::SummaryChanged); cx.emit(ContextEvent::SummaryChanged);
cx.emit(ContextEvent::SummaryGenerated);
cx.notify(); cx.notify();
} }
} }
@ -2947,7 +2949,7 @@ impl AssistantContext {
self.message_anchors.insert(insertion_ix, new_anchor); 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 { let Some(model) = LanguageModelRegistry::read_global(cx).default_model() else {
return; return;
}; };
@ -2967,7 +2969,18 @@ impl AssistantContext {
cache: false, 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 { async move {
let stream = model.model.stream_completion_text(request, &cx); let stream = model.model.stream_completion_text(request, &cx);
let mut messages = stream.await?; let mut messages = stream.await?;
@ -2992,6 +3005,7 @@ impl AssistantContext {
}; };
this.push_op(operation, cx); this.push_op(operation, cx);
cx.emit(ContextEvent::SummaryChanged); cx.emit(ContextEvent::SummaryChanged);
cx.emit(ContextEvent::SummaryGenerated);
})?; })?;
// Stop if the LLM generated multiple lines. // Stop if the LLM generated multiple lines.
@ -3012,6 +3026,7 @@ impl AssistantContext {
}; };
this.push_op(operation, cx); this.push_op(operation, cx);
cx.emit(ContextEvent::SummaryChanged); 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 timestamp = self.next_timestamp();
let summary = self.summary.get_or_insert(ContextSummary::default()); let summary = self.summary.get_or_insert(ContextSummary::default());
summary.timestamp = timestamp; summary.timestamp = timestamp;
@ -3192,6 +3207,15 @@ impl AssistantContext {
summary.text = custom_summary; summary.text = custom_summary;
cx.emit(ContextEvent::SummaryChanged); 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 { fn trimmed_text_in_range(buffer: &BufferSnapshot, range: Range<text::Anchor>) -> String {

View file

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