Detailed changes
@@ -671,13 +671,14 @@
},
},
{
- "context": "WorkspaceSidebar",
+ "context": "ThreadsSidebar",
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "multi_workspace::NewWorkspaceInWindow",
"left": "agents_sidebar::CollapseSelectedEntry",
"right": "agents_sidebar::ExpandSelectedEntry",
"enter": "menu::Confirm",
+ "shift-backspace": "agent::RemoveSelectedThread",
},
},
{
@@ -739,13 +739,14 @@
},
},
{
- "context": "WorkspaceSidebar",
+ "context": "ThreadsSidebar",
"use_key_equivalents": true,
"bindings": {
"cmd-n": "multi_workspace::NewWorkspaceInWindow",
"left": "agents_sidebar::CollapseSelectedEntry",
"right": "agents_sidebar::ExpandSelectedEntry",
"enter": "menu::Confirm",
+ "shift-backspace": "agent::RemoveSelectedThread",
},
},
{
@@ -675,13 +675,14 @@
},
},
{
- "context": "WorkspaceSidebar",
+ "context": "ThreadsSidebar",
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "multi_workspace::NewWorkspaceInWindow",
"left": "agents_sidebar::CollapseSelectedEntry",
"right": "agents_sidebar::ExpandSelectedEntry",
"enter": "menu::Confirm",
+ "shift-backspace": "agent::RemoveSelectedThread",
},
},
{
@@ -1207,6 +1207,10 @@ impl AcpThread {
.unwrap_or_else(|| self.title.clone())
}
+ pub fn has_provisional_title(&self) -> bool {
+ self.provisional_title.is_some()
+ }
+
pub fn entries(&self) -> &[AgentThreadEntry] {
&self.entries
}
@@ -2570,6 +2570,14 @@ impl Thread {
.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| {
+ cx.emit(TitleUpdated);
+ cx.notify();
+ });
}
_ = this.update(cx, |this, _| this.pending_title_generation = None);
}));
@@ -3266,48 +3266,49 @@ impl AgentPanel {
let content = match &self.active_view {
ActiveView::AgentThread { server_view } => {
- let is_generating_title = server_view
- .read(cx)
- .as_native_thread(cx)
- .map_or(false, |t| t.read(cx).is_generating_title());
+ let server_view_ref = server_view.read(cx);
+ let is_generating_title = server_view_ref.as_native_thread(cx).is_some()
+ && server_view_ref.parent_thread(cx).map_or(false, |tv| {
+ tv.read(cx).thread.read(cx).has_provisional_title()
+ });
- if let Some(title_editor) = server_view
- .read(cx)
+ if let Some(title_editor) = server_view_ref
.parent_thread(cx)
.map(|r| r.read(cx).title_editor.clone())
{
- let container = div()
- .w_full()
- .on_action({
- let thread_view = server_view.downgrade();
- move |_: &menu::Confirm, window, cx| {
- if let Some(thread_view) = thread_view.upgrade() {
- thread_view.focus_handle(cx).focus(window, cx);
- }
- }
- })
- .on_action({
- let thread_view = server_view.downgrade();
- move |_: &editor::actions::Cancel, window, cx| {
- if let Some(thread_view) = thread_view.upgrade() {
- thread_view.focus_handle(cx).focus(window, cx);
- }
- }
- })
- .child(title_editor);
-
if is_generating_title {
- container
+ Label::new("New Threadβ¦")
+ .color(Color::Muted)
+ .truncate()
.with_animation(
"generating_title",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
- |div, delta| div.opacity(delta),
+ |label, delta| label.alpha(delta),
)
.into_any_element()
} else {
- container.into_any_element()
+ div()
+ .w_full()
+ .on_action({
+ let thread_view = server_view.downgrade();
+ move |_: &menu::Confirm, window, cx| {
+ if let Some(thread_view) = thread_view.upgrade() {
+ thread_view.focus_handle(cx).focus(window, cx);
+ }
+ }
+ })
+ .on_action({
+ let thread_view = server_view.downgrade();
+ move |_: &editor::actions::Cancel, window, cx| {
+ if let Some(thread_view) = thread_view.upgrade() {
+ thread_view.focus_handle(cx).focus(window, cx);
+ }
+ }
+ })
+ .child(title_editor)
+ .into_any_element()
}
} else {
Label::new(server_view.read(cx).title(cx))
@@ -1,5 +1,5 @@
use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent};
-use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread};
+use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread};
use acp_thread::ThreadStatus;
use action_log::DiffStats;
use agent::ThreadStore;
@@ -83,6 +83,7 @@ struct ActiveThreadInfo {
icon: IconName,
icon_from_external_svg: Option<SharedString>,
is_background: bool,
+ is_title_generating: bool,
diff_stats: DiffStats,
}
@@ -115,6 +116,7 @@ struct ThreadEntry {
workspace: ThreadEntryWorkspace,
is_live: bool,
is_background: bool,
+ is_title_generating: bool,
highlight_positions: Vec<usize>,
worktree_name: Option<SharedString>,
worktree_highlight_positions: Vec<usize>,
@@ -453,6 +455,8 @@ impl Sidebar {
let icon = thread_view_ref.agent_icon;
let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
let title = thread.title();
+ let is_native = thread_view_ref.as_native_thread(cx).is_some();
+ let is_title_generating = is_native && thread.has_provisional_title();
let session_id = thread.session_id().clone();
let is_background = agent_panel_ref.is_background_thread(&session_id);
@@ -476,6 +480,7 @@ impl Sidebar {
icon,
icon_from_external_svg,
is_background,
+ is_title_generating,
diff_stats,
}
})
@@ -593,6 +598,7 @@ impl Sidebar {
workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: false,
is_background: false,
+ is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: None,
worktree_highlight_positions: Vec::new(),
@@ -646,6 +652,7 @@ impl Sidebar {
workspace: target_workspace.clone(),
is_live: false,
is_background: false,
+ is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: Some(worktree_name.clone()),
worktree_highlight_positions: Vec::new(),
@@ -676,6 +683,7 @@ impl Sidebar {
thread.icon_from_external_svg = info.icon_from_external_svg.clone();
thread.is_live = true;
thread.is_background = info.is_background;
+ thread.is_title_generating = info.is_title_generating;
thread.diff_stats = info.diff_stats;
}
}
@@ -1030,6 +1038,7 @@ impl Sidebar {
.end_hover_gradient_overlay(true)
.end_hover_slot(
h_flex()
+ .gap_1()
.when(workspace_count > 1, |this| {
this.child(
IconButton::new(
@@ -1588,7 +1597,6 @@ impl Sidebar {
let Some(thread_store) = ThreadStore::try_global(cx) else {
return;
};
- self.hovered_thread_index = None;
thread_store.update(cx, |store, cx| {
store
.delete_thread(session_id.clone(), cx)
@@ -1596,6 +1604,25 @@ impl Sidebar {
});
}
+ fn remove_selected_thread(
+ &mut self,
+ _: &RemoveSelectedThread,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(ix) = self.selection else {
+ return;
+ };
+ let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
+ return;
+ };
+ if thread.agent != Agent::NativeAgent {
+ return;
+ }
+ let session_id = thread.session_info.session_id.clone();
+ self.delete_thread(&session_id, cx);
+ }
+
fn render_thread(
&self,
ix: usize,
@@ -1620,6 +1647,7 @@ impl Sidebar {
let is_selected = self.focused_thread.as_ref() == Some(&session_info.session_id);
let can_delete = thread.agent == Agent::NativeAgent;
let session_id_for_delete = thread.session_info.session_id.clone();
+ let focus_handle = self.focus_handle.clone();
let id = SharedString::from(format!("thread-entry-{}", ix));
@@ -1660,6 +1688,7 @@ impl Sidebar {
.when_some(timestamp, |this, ts| this.timestamp(ts))
.highlight_positions(thread.highlight_positions.to_vec())
.status(thread.status)
+ .generating_title(thread.is_title_generating)
.notified(has_notification)
.when(thread.diff_stats.lines_added > 0, |this| {
this.added(thread.diff_stats.lines_added as usize)
@@ -1679,16 +1708,27 @@ impl Sidebar {
}
cx.notify();
}))
- .when((is_hovered || is_selected) && can_delete, |this| {
+ .when(is_hovered && can_delete, |this| {
this.action_slot(
IconButton::new("delete-thread", IconName::Trash)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
- .tooltip(Tooltip::text("Delete Thread"))
+ .tooltip({
+ let focus_handle = focus_handle.clone();
+ move |_window, cx| {
+ Tooltip::for_action_in(
+ "Delete Thread",
+ &RemoveSelectedThread,
+ &focus_handle,
+ cx,
+ )
+ }
+ })
.on_click({
let session_id = session_id_for_delete.clone();
cx.listener(move |this, _, _window, cx| {
this.delete_thread(&session_id, cx);
+ cx.stop_propagation();
})
}),
)
@@ -1848,17 +1888,30 @@ impl Sidebar {
this.child(self.render_sidebar_toggle_button(false, cx))
})
.child(self.render_filter_input())
- .when(has_query, |this| {
- this.when(!docked_right, |this| this.pr_1p5()).child(
- IconButton::new("clear_filter", IconName::Close)
- .shape(IconButtonShape::Square)
- .tooltip(Tooltip::text("Clear Search"))
- .on_click(cx.listener(|this, _, window, cx| {
- this.reset_filter_editor_text(window, cx);
- this.update_entries(cx);
- })),
- )
- })
+ .child(
+ h_flex()
+ .gap_0p5()
+ .when(!docked_right, |this| this.pr_1p5())
+ .when(has_query, |this| {
+ this.child(
+ IconButton::new("clear_filter", IconName::Close)
+ .shape(IconButtonShape::Square)
+ .tooltip(Tooltip::text("Clear Search"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.reset_filter_editor_text(window, cx);
+ this.update_entries(cx);
+ })),
+ )
+ })
+ .child(
+ IconButton::new("archive", IconName::Archive)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Archive"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.show_archive(window, cx);
+ })),
+ ),
+ )
.when(docked_right, |this| {
this.pl_2()
.pr_0p5()
@@ -1866,27 +1919,6 @@ impl Sidebar {
})
}
- fn render_thread_list_footer(&self, cx: &mut Context<Self>) -> impl IntoElement {
- h_flex()
- .p_1p5()
- .border_t_1()
- .border_color(cx.theme().colors().border_variant)
- .child(
- Button::new("view-archive", "Archive")
- .full_width()
- .label_size(LabelSize::Small)
- .style(ButtonStyle::Outlined)
- .start_icon(
- Icon::new(IconName::Archive)
- .size(IconSize::XSmall)
- .color(Color::Muted),
- )
- .on_click(cx.listener(|this, _, window, cx| {
- this.show_archive(window, cx);
- })),
- )
- }
-
fn render_sidebar_toggle_button(
&self,
docked_right: bool,
@@ -2064,7 +2096,7 @@ impl Render for Sidebar {
v_flex()
.id("workspace-sidebar")
- .key_context("WorkspaceSidebar")
+ .key_context("ThreadsSidebar")
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_previous))
@@ -2076,6 +2108,7 @@ impl Render for Sidebar {
.on_action(cx.listener(Self::expand_selected_entry))
.on_action(cx.listener(Self::collapse_selected_entry))
.on_action(cx.listener(Self::cancel))
+ .on_action(cx.listener(Self::remove_selected_thread))
.font(ui_font)
.size_full()
.bg(cx.theme().colors().surface_background)
@@ -2097,8 +2130,7 @@ impl Render for Sidebar {
)
.when_some(sticky_header, |this, header| this.child(header))
.vertical_scrollbar_for(&self.list_state, window, cx),
- )
- .child(self.render_thread_list_footer(cx)),
+ ),
SidebarView::Archive => {
if let Some(archive_view) = &self.archive_view {
this.child(archive_view.clone())
@@ -2641,6 +2673,7 @@ mod tests {
workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: false,
is_background: false,
+ is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: None,
worktree_highlight_positions: Vec::new(),
@@ -2663,6 +2696,7 @@ mod tests {
workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: true,
is_background: false,
+ is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: None,
worktree_highlight_positions: Vec::new(),
@@ -2685,6 +2719,7 @@ mod tests {
workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: true,
is_background: false,
+ is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: None,
worktree_highlight_positions: Vec::new(),
@@ -2707,6 +2742,7 @@ mod tests {
workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: false,
is_background: false,
+ is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: None,
worktree_highlight_positions: Vec::new(),
@@ -2729,6 +2765,7 @@ mod tests {
workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: true,
is_background: true,
+ is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: None,
worktree_highlight_positions: Vec::new(),
@@ -1,8 +1,12 @@
use std::sync::Arc;
-use crate::{Agent, agent_connection_store::AgentConnectionStore, thread_history::ThreadHistory};
+use crate::{
+ Agent, RemoveSelectedThread, agent_connection_store::AgentConnectionStore,
+ thread_history::ThreadHistory,
+};
use acp_thread::AgentSessionInfo;
use agent::ThreadStore;
+use agent_client_protocol as acp;
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
use editor::Editor;
use fs::Fs;
@@ -109,6 +113,7 @@ pub struct ThreadsArchiveView {
list_state: ListState,
items: Vec<ArchiveListItem>,
selection: Option<usize>,
+ hovered_index: Option<usize>,
filter_editor: Entity<Editor>,
_subscriptions: Vec<gpui::Subscription>,
selected_agent_menu: PopoverMenuHandle<ContextMenu>,
@@ -152,6 +157,7 @@ impl ThreadsArchiveView {
list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
items: Vec::new(),
selection: None,
+ hovered_index: None,
filter_editor,
_subscriptions: vec![filter_editor_subscription],
selected_agent_menu: PopoverMenuHandle::default(),
@@ -272,6 +278,37 @@ impl ThreadsArchiveView {
});
}
+ fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
+ let Some(history) = &self.history else {
+ return;
+ };
+ if !history.read(cx).supports_delete() {
+ return;
+ }
+ let session_id = session_id.clone();
+ history.update(cx, |history, cx| {
+ history
+ .delete_session(&session_id, cx)
+ .detach_and_log_err(cx);
+ });
+ }
+
+ fn remove_selected_thread(
+ &mut self,
+ _: &RemoveSelectedThread,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(ix) = self.selection else {
+ return;
+ };
+ let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
+ return;
+ };
+ let session_id = session.session_id.clone();
+ self.delete_thread(&session_id, cx);
+ }
+
fn is_selectable_item(&self, ix: usize) -> bool {
matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
}
@@ -377,9 +414,17 @@ impl ThreadsArchiveView {
highlight_positions,
} => {
let is_selected = self.selection == Some(ix);
+ let hovered = self.hovered_index == Some(ix);
+ let supports_delete = self
+ .history
+ .as_ref()
+ .map(|h| h.read(cx).supports_delete())
+ .unwrap_or(false);
let title: SharedString =
session.title.clone().unwrap_or_else(|| "Untitled".into());
let session_info = session.clone();
+ let session_id_for_delete = session.session_id.clone();
+ let focus_handle = self.focus_handle.clone();
let highlight_positions = highlight_positions.clone();
let timestamp = session.created_at.or(session.updated_at).map(|entry_time| {
@@ -429,12 +474,45 @@ impl ThreadsArchiveView {
.gap_2()
.justify_between()
.child(title_label)
- .when_some(timestamp, |this, ts| {
- this.child(
- Label::new(ts).size(LabelSize::Small).color(Color::Muted),
- )
+ .when(!(hovered && supports_delete), |this| {
+ this.when_some(timestamp, |this, ts| {
+ this.child(
+ Label::new(ts).size(LabelSize::Small).color(Color::Muted),
+ )
+ })
}),
)
+ .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
+ if *is_hovered {
+ this.hovered_index = Some(ix);
+ } else if this.hovered_index == Some(ix) {
+ this.hovered_index = None;
+ }
+ cx.notify();
+ }))
+ .end_slot::<IconButton>(if hovered && supports_delete {
+ Some(
+ IconButton::new("delete-thread", IconName::Trash)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .tooltip({
+ move |_window, cx| {
+ Tooltip::for_action_in(
+ "Delete Thread",
+ &RemoveSelectedThread,
+ &focus_handle,
+ cx,
+ )
+ }
+ })
+ .on_click(cx.listener(move |this, _, _, cx| {
+ this.delete_thread(&session_id_for_delete, cx);
+ cx.stop_propagation();
+ })),
+ )
+ } else {
+ None
+ })
.on_click(cx.listener(move |this, _, window, cx| {
this.open_thread(session_info.clone(), window, cx);
}))
@@ -683,6 +761,7 @@ impl Render for ThreadsArchiveView {
.on_action(cx.listener(Self::select_first))
.on_action(cx.listener(Self::select_last))
.on_action(cx.listener(Self::confirm))
+ .on_action(cx.listener(Self::remove_selected_thread))
.size_full()
.bg(cx.theme().colors().surface_background)
.child(self.render_header(cx))
@@ -3,7 +3,8 @@ use crate::{
IconDecorationKind, prelude::*,
};
-use gpui::{AnyView, ClickEvent, Hsla, SharedString};
+use gpui::{Animation, AnimationExt, AnyView, ClickEvent, Hsla, SharedString, pulsating_between};
+use std::time::Duration;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AgentThreadStatus {
@@ -23,6 +24,7 @@ pub struct ThreadItem {
timestamp: SharedString,
notified: bool,
status: AgentThreadStatus,
+ generating_title: bool,
selected: bool,
focused: bool,
hovered: bool,
@@ -48,6 +50,7 @@ impl ThreadItem {
timestamp: "".into(),
notified: false,
status: AgentThreadStatus::default(),
+ generating_title: false,
selected: false,
focused: false,
hovered: false,
@@ -89,6 +92,11 @@ impl ThreadItem {
self
}
+ pub fn generating_title(mut self, generating: bool) -> Self {
+ self.generating_title = generating;
+ self
+ }
+
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
@@ -221,7 +229,18 @@ impl RenderOnce for ThreadItem {
let title = self.title;
let highlight_positions = self.highlight_positions;
- let title_label = if highlight_positions.is_empty() {
+ let title_label = if self.generating_title {
+ Label::new("New Threadβ¦")
+ .color(Color::Muted)
+ .with_animation(
+ "generating-title",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.4, 0.8)),
+ |label, delta| label.alpha(delta),
+ )
+ .into_any_element()
+ } else if highlight_positions.is_empty() {
Label::new(title).into_any_element()
} else {
HighlightedLabel::new(title, highlight_positions).into_any_element()
@@ -283,7 +302,7 @@ impl RenderOnce for ThreadItem {
.when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
)
.child(gradient_overlay)
- .when(self.hovered || self.selected, |this| {
+ .when(self.hovered, |this| {
this.when_some(self.action_slot, |this, slot| {
let overlay = GradientFade::new(
base_bg,