diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 349c980e8b..c66d20f13f 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -729,6 +729,7 @@ "use_key_equivalents": true, "bindings": { "space": "menu::Confirm", + "shift-r": "agent::RenameSelectedThread", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 23fd201b0d..61e719d712 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -785,6 +785,7 @@ "use_key_equivalents": true, "bindings": { "space": "menu::Confirm", + "shift-r": "agent::RenameSelectedThread", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 3eece808cc..56a3bc4972 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -732,6 +732,7 @@ "use_key_equivalents": true, "bindings": { "space": "menu::Confirm", + "shift-r": "agent::RenameSelectedThread", }, }, { diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 755c7a0abb..8472d96e7d 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -3646,6 +3646,22 @@ impl AgentPanel { } } + pub fn conversation_view_for_id( + &self, + thread_id: &ThreadId, + cx: &App, + ) -> Option<&Entity> { + self.retained_threads.get(thread_id).or_else(|| { + if let Some(view) = self.active_conversation_view() + && view.read(cx).thread_id == *thread_id + { + Some(view) + } else { + None + } + }) + } + pub fn conversation_views(&self) -> Vec> { self.active_conversation_view() .into_iter() diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 807d0bdfd0..2ccd27ce08 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -199,6 +199,8 @@ actions!( ArchiveSelectedThread, /// Removes the currently selected thread. RemoveSelectedThread, + /// Renames the currently selected thread. + RenameSelectedThread, /// Starts a chat conversation with follow-up enabled. ChatWithFollow, /// Cycles to the next inline assist suggestion. diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index f310a29b6d..6bcdd9151a 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -2008,8 +2008,6 @@ impl ThreadView { window: &mut Window, cx: &mut Context, ) { - let thread = &self.thread; - match event { EditorEvent::BufferEdited => { // We only want to set the title if the user has actively edited @@ -2023,20 +2021,7 @@ impl ThreadView { if new_title.is_empty() { return; } - let title = SharedString::from(new_title); - if let Some(store) = ThreadMetadataStore::try_global(cx) - && !self.is_subagent() - { - let thread_id = self.root_thread_id; - store.update(cx, |store, cx| { - store.set_title_override(thread_id, title.clone(), cx); - }); - } - thread.update(cx, |thread, cx| { - if thread.can_set_title(cx) { - thread.set_title(title, cx).detach_and_log_err(cx); - } - }); + self.apply_renamed_title(SharedString::from(new_title), cx); } EditorEvent::Blurred => { if title_editor.read(cx).text(cx).is_empty() { @@ -2049,6 +2034,35 @@ impl ThreadView { } } + /// Renames the thread, mirroring the editor text and persisting the new + /// title. Used by callers outside of the title editor (e.g. the sidebar's + /// inline rename) so that they go through the same persistence path as + /// the in-thread title editor. + pub fn rename(&mut self, title: SharedString, window: &mut Window, cx: &mut Context) { + if self.title_editor.read(cx).text(cx) != title.as_ref() { + self.title_editor.update(cx, |editor, cx| { + editor.set_text(title.clone(), window, cx); + }); + } + self.apply_renamed_title(title, cx); + } + + fn apply_renamed_title(&mut self, title: SharedString, cx: &mut Context) { + if let Some(store) = ThreadMetadataStore::try_global(cx) + && !self.is_subagent() + { + let thread_id = self.root_thread_id; + store.update(cx, |store, cx| { + store.set_title_override(thread_id, title.clone(), cx); + }); + } + self.thread.update(cx, |thread, cx| { + if thread.can_set_title(cx) { + thread.set_title(title, cx).detach_and_log_err(cx); + } + }); + } + pub fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { if let Some(index) = self.editing_message.take() && let Some(editor) = &self diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 4b8514c9f6..948650d095 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -18,8 +18,8 @@ use agent_ui::threads_archive_view::{ use agent_ui::{ AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, AgentThreadSource, ArchiveSelectedThread, CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewTerminalThread, - NewThread, TerminalId, ThreadId, ThreadImportModal, channels_with_threads, - import_threads_from_other_channels, + NewThread, RenameSelectedThread, TerminalId, ThreadId, ThreadImportModal, + channels_with_threads, import_threads_from_other_channels, }; use chrono::{DateTime, Utc}; use editor::Editor; @@ -654,6 +654,7 @@ pub struct Sidebar { width: Pixels, focus_handle: FocusHandle, filter_editor: Entity, + thread_rename_editor: Entity, list_state: ListState, contents: SidebarContents, /// The index of the list item that currently has the keyboard focus @@ -663,6 +664,10 @@ pub struct Sidebar { /// Tracks which sidebar entry is currently active (highlighted). active_entry: Option, hovered_thread_index: Option, + renaming_thread_id: Option, + /// start_renaming_thread must seed current title into the title editor + /// so this prevents that BufferEdited event from being interpreted as user input. + suppress_next_rename_edit: bool, /// Updated only in response to explicit user actions (clicking a /// thread, confirming in the thread switcher, etc.) — never from @@ -708,6 +713,7 @@ impl Sidebar { editor.set_placeholder_text("Search threads…", window, cx); editor }); + let thread_rename_editor = cx.new(|cx| Editor::single_line(window, cx)); cx.subscribe_in( &multi_workspace, @@ -744,6 +750,15 @@ impl Sidebar { }) .detach(); + cx.subscribe_in( + &thread_rename_editor, + window, + |this, title_editor, event, window, cx| { + this.handle_thread_rename_editor_event(title_editor, event, window, cx); + }, + ) + .detach(); + cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| { this.update_entries(cx); }) @@ -773,11 +788,14 @@ impl Sidebar { width: DEFAULT_WIDTH, focus_handle, filter_editor, + thread_rename_editor, list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), contents: SidebarContents::default(), selection: None, active_entry: None, hovered_thread_index: None, + renaming_thread_id: None, + suppress_next_rename_edit: false, thread_last_accessed: HashMap::new(), terminal_last_accessed: HashMap::new(), @@ -1692,12 +1710,8 @@ impl Sidebar { let mut matched_threads: Vec = Vec::new(); for mut thread in threads { - let title: &str = thread - .metadata - .title - .as_ref() - .map_or(DEFAULT_THREAD_TITLE, |t| t.as_ref()); - if let Some(positions) = fuzzy_match_positions(&query, title) { + let title = thread.metadata.display_title(); + if let Some(positions) = fuzzy_match_positions(&query, title.as_ref()) { thread.highlight_positions = positions; } let mut worktree_matched = false; @@ -2800,10 +2814,17 @@ impl Sidebar { let is_archived_search_focused = matches!(&self.view, SidebarView::Archive(archive) if archive.read(cx).is_filter_editor_focused(window, cx)); + let is_renaming_thread = self + .thread_rename_editor + .focus_handle(cx) + .is_focused(window); + let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) || is_archived_search_focused { "searching" + } else if is_renaming_thread { + "editing" } else { "not_searching" }; @@ -2828,6 +2849,11 @@ impl Sidebar { } fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + if self.renaming_thread_id.is_some() { + self.finish_thread_rename(window, cx); + return; + } + if self.filter_editor.read(cx).is_focused(window) { if self.reset_filter_editor_text(window, cx) { self.selection = None; @@ -2888,6 +2914,104 @@ impl Sidebar { !self.filter_editor.read(cx).text(cx).is_empty() } + fn start_renaming_thread( + &mut self, + ix: usize, + thread_id: ThreadId, + title: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + if self.renaming_thread_id.is_some() && self.renaming_thread_id != Some(thread_id) { + self.finish_thread_rename(window, cx); + } + + self.selection = Some(ix); + self.renaming_thread_id = Some(thread_id); + self.suppress_next_rename_edit = true; + self.list_state.scroll_to_reveal_item(ix); + self.thread_rename_editor.update(cx, |editor, cx| { + editor.set_text(title, window, cx); + editor.select_all(&editor::actions::SelectAll, window, cx); + editor.focus_handle(cx).focus(window, cx); + }); + cx.notify(); + } + + fn handle_thread_rename_editor_event( + &mut self, + title_editor: &Entity, + event: &editor::EditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + editor::EditorEvent::BufferEdited => { + if self.suppress_next_rename_edit { + self.suppress_next_rename_edit = false; + return; + } + if !title_editor.read(cx).is_focused(window) { + return; + } + let new_title = title_editor.read(cx).text(cx); + if new_title.is_empty() { + return; + } + let Some(thread_id) = self.renaming_thread_id else { + return; + }; + self.apply_thread_rename(thread_id, SharedString::from(new_title), window, cx); + } + editor::EditorEvent::Blurred => { + self.finish_thread_rename(window, cx); + } + _ => {} + } + } + + fn apply_thread_rename( + &mut self, + thread_id: ThreadId, + title: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + let mut found = false; + if let Some(multi_workspace) = self.multi_workspace.upgrade() { + let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); + for workspace in workspaces { + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + if let Some(view) = agent_panel + .read(cx) + .conversation_view_for_id(&thread_id, cx) + && let Some(thread_view) = view.read(cx).root_thread_view() + { + thread_view.update(cx, |thread_view, cx| { + thread_view.rename(title.clone(), window, cx); + }); + found = true; + } + } + } + } + + if !found { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.set_title_override(thread_id, title, cx); + }); + } + } + + fn finish_thread_rename(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if self.renaming_thread_id.take().is_none() { + return false; + } + self.focus_handle.focus(window, cx); + self.update_entries(cx); + true + } + fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { self.select_next(&SelectNext, window, cx); if self.selection.is_some() { @@ -2962,6 +3086,10 @@ impl Sidebar { } fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + if self.finish_thread_rename(window, cx) { + return; + } + let Some(ix) = self.selection else { return }; let Some(entry) = self.contents.entries.get(ix) else { return; @@ -5062,6 +5190,23 @@ impl Sidebar { } } + fn rename_selected_thread( + &mut self, + _: &RenameSelectedThread, + window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { + return; + }; + let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else { + return; + }; + let thread_id = thread.metadata.thread_id; + let title = thread.metadata.display_title(); + self.start_renaming_thread(ix, thread_id, title, window, cx); + } + fn record_thread_access(&mut self, id: &ThreadId) { self.thread_last_accessed.insert(*id, Utc::now()); } @@ -5492,10 +5637,12 @@ impl Sidebar { thread.status, AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation ); + let is_renaming = self.renaming_thread_id == Some(thread.metadata.thread_id); let thread_id_for_actions = thread.metadata.thread_id; let session_id_for_delete = thread.metadata.session_id.clone(); let focus_handle = self.focus_handle.clone(); + let title_editor = self.thread_rename_editor.clone(); let id = SharedString::from(format!("thread-entry-{}", ix)); @@ -5519,7 +5666,7 @@ impl Sidebar { (thread.icon, thread.icon_from_external_svg.clone()) }; - ThreadItem::new(id, title) + ThreadItem::new(id, title.clone()) .base_bg(sidebar_bg) .icon(icon) .when(is_draft, |this| { @@ -5552,22 +5699,84 @@ impl Sidebar { } cx.notify(); })) - .when(is_hovered && is_running, |this| { - this.action_slot( + .when(is_renaming, |this| { + this.is_truncated(false).title_slot( + div() + .h_full() + .min_w_0() + .flex_1() + .capture_action(cx.listener( + |this, _: &editor::actions::Newline, window, cx| { + this.finish_thread_rename(window, cx); + }, + )) + .on_action(cx.listener(|this, _: &Confirm, window, cx| { + this.finish_thread_rename(window, cx); + })) + .on_action( + cx.listener(|this, _: &editor::actions::Cancel, window, cx| { + this.finish_thread_rename(window, cx); + }), + ) + .child(title_editor), + ) + }) + .when(is_hovered && !is_renaming, |this| { + let rename_button = IconButton::new(("rename-thread", ix), IconName::Pencil) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Rename Thread", + &RenameSelectedThread, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let title = title.clone(); + cx.listener(move |this, _, window, cx| { + this.start_renaming_thread( + ix, + thread_id_for_actions, + title.clone(), + window, + cx, + ); + }) + }); + + let contextual_action = if is_running { IconButton::new("stop-thread", IconName::Stop) .icon_size(IconSize::Small) .icon_color(Color::Error) .style(ButtonStyle::Tinted(TintColor::Error)) .tooltip(Tooltip::text("Stop Generation")) + .on_click(cx.listener(move |this, _, _window, cx| { + this.stop_thread(&thread_id_for_actions, cx); + })) + .into_any_element() + } else if is_draft { + IconButton::new("discard_thread", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Discard Draft")) .on_click({ - cx.listener(move |this, _, _window, cx| { - this.stop_thread(&thread_id_for_actions, cx); + let thread_workspace = thread_workspace.clone(); + cx.listener(move |this, _, window, cx| { + this.remove_draft( + thread_id_for_actions, + &thread_workspace, + window, + cx, + ); }) - }), - ) - }) - .when(is_hovered && !is_running && !is_draft, |this| { - this.action_slot( + }) + .into_any_element() + } else { IconButton::new("archive-thread", IconName::Archive) .icon_size(IconSize::Small) .icon_color(Color::Muted) @@ -5589,26 +5798,15 @@ impl Sidebar { this.archive_thread(session_id, window, cx); } }) - }), - ) - }) - .when(is_hovered && !is_running && is_draft, |this| { + }) + .into_any_element() + }; + this.action_slot( - IconButton::new("discard_thread", IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Discard Draft")) - .on_click({ - let thread_workspace = thread_workspace.clone(); - cx.listener(move |this, _, window, cx| { - this.remove_draft( - thread_id_for_actions, - &thread_workspace, - window, - cx, - ); - }) - }), + h_flex() + .gap_0p5() + .child(rename_button) + .child(contextual_action), ) }) .on_click({ @@ -7035,6 +7233,7 @@ impl Render for Sidebar { .on_action(cx.listener(Self::unfold_all)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::archive_selected_thread)) + .on_action(cx.listener(Self::rename_selected_thread)) .on_action(cx.listener(Self::new_thread_in_group)) .on_action(cx.listener(Self::new_terminal_thread)) .on_action(cx.listener(Self::toggle_archive)) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 3fcb27faf4..97ddea152d 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -4886,6 +4886,203 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) ); } +#[gpui::test] +async fn test_rename_thread_from_sidebar_updates_title_override(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Hi there!".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, &project, cx).await; + cx.run_until_parked(); + + let (entry_ix, thread_id, title) = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .enumerate() + .find_map(|(ix, entry)| match entry { + ListEntry::Thread(thread) => Some(( + ix, + thread.metadata.thread_id, + thread.metadata.display_title(), + )), + ListEntry::ProjectHeader { .. } | ListEntry::Terminal(_) => None, + }) + .expect("sidebar should have a thread entry") + }); + + let renamed_title = "abcdefghijklmnopqrstuvwxyé renamed"; + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.start_renaming_thread(entry_ix, thread_id, title, window, cx); + }); + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.thread_rename_editor.update(cx, |editor, cx| { + editor.set_text(renamed_title, window, cx); + }); + }); + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.finish_thread_rename(window, cx); + }); + cx.run_until_parked(); + + let metadata = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(thread_id) + .cloned() + .expect("thread metadata should exist") + }); + assert_eq!(metadata.title_override.as_deref(), Some(renamed_title)); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [my-project]", + " abcdefghijklmnopqrstuvwxyé renamed * <== selected", + ] + ); + + let active_thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); + assert_eq!( + active_thread.read_with(cx, |thread, _| thread.title()), + Some(renamed_title.into()) + ); + let active_thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap()); + let title_editor_text = + active_thread_view.read_with(cx, |view, cx| view.title_editor.read(cx).text(cx)); + assert_eq!(title_editor_text, renamed_title); + + active_thread.update(cx, |thread, cx| { + thread + .set_title("abcdefghijklmnopqrstuvwxyz0".into(), cx) + .detach(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [my-project]", + " abcdefghijklmnopqrstuvwxyé renamed * <== selected", + ] + ); + + type_in_search(&sidebar, "0", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); + + type_in_search(&sidebar, "é", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [my-project]", + " abcdefghijklmnopqrstuvwxyé renamed * <== selected", + ] + ); + sidebar.read_with(cx, |sidebar, _cx| { + let thread = sidebar + .contents + .entries + .iter() + .find_map(|entry| match entry { + ListEntry::Thread(thread) => Some(thread), + ListEntry::ProjectHeader { .. } | ListEntry::Terminal(_) => None, + }) + .expect("renamed thread should match the search"); + let title = thread.metadata.display_title(); + assert!( + thread + .highlight_positions + .iter() + .all(|position| { title.is_char_boundary(*position) }) + ); + }); +} + +#[gpui::test] +async fn test_rename_selected_thread_action_renames_selected_thread(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Hi there!".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, &project, cx).await; + cx.run_until_parked(); + + let (entry_ix, thread_id) = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .enumerate() + .find_map(|(ix, entry)| match entry { + ListEntry::Thread(thread) => Some((ix, thread.metadata.thread_id)), + ListEntry::ProjectHeader { .. } | ListEntry::Terminal(_) => None, + }) + .expect("sidebar should have a thread entry") + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(entry_ix); + }); + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.renaming_thread_id, + Some(thread_id), + "dispatching RenameSelectedThread should start renaming the selected thread" + ); + }); + + let renamed_title = "Renamed via action"; + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.thread_rename_editor.update(cx, |editor, cx| { + editor.set_text(renamed_title, window, cx); + }); + }); + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.finish_thread_rename(window, cx); + }); + cx.run_until_parked(); + + let metadata = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(thread_id) + .cloned() + .expect("thread metadata should exist") + }); + assert_eq!(metadata.title_override.as_deref(), Some(renamed_title)); +} + #[gpui::test] async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { let project_a = init_test_project_with_agent_panel("/project-a", cx).await; diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index c962aa4f9f..99f07f6faa 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -39,6 +39,7 @@ pub struct ThreadItem { icon_visible: bool, custom_icon_from_external_svg: Option, title: SharedString, + title_slot: Option, title_label_color: Option, title_generating: bool, highlight_positions: Vec, @@ -49,6 +50,7 @@ pub struct ThreadItem { focused: bool, hovered: bool, rounded: bool, + is_truncated: bool, added: Option, removed: Option, project_paths: Option>, @@ -71,6 +73,7 @@ impl ThreadItem { icon_visible: true, custom_icon_from_external_svg: None, title: title.into(), + title_slot: None, title_label_color: None, title_generating: false, highlight_positions: Vec::new(), @@ -81,6 +84,7 @@ impl ThreadItem { focused: false, hovered: false, rounded: false, + is_truncated: true, added: None, removed: None, project_paths: None, @@ -140,6 +144,11 @@ impl ThreadItem { self } + pub fn title_slot(mut self, element: impl IntoElement) -> Self { + self.title_slot = Some(element.into_any_element()); + self + } + pub fn highlight_positions(mut self, positions: Vec) -> Self { self.highlight_positions = positions; self @@ -200,6 +209,11 @@ impl ThreadItem { self } + pub fn is_truncated(mut self, is_truncated: bool) -> Self { + self.is_truncated = is_truncated; + self + } + pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -317,7 +331,9 @@ impl RenderOnce for ThreadItem { let title = self.title; let highlight_positions = self.highlight_positions; - let title_label = if self.title_generating { + let title_label = if let Some(title_slot) = self.title_slot { + title_slot + } else if self.title_generating { Label::new(title) .color(Color::Muted) .with_animation( @@ -403,6 +419,7 @@ impl RenderOnce for ThreadItem { h_flex() .min_w_0() .w_full() + .h_6() .gap_2() .justify_between() .child( @@ -414,7 +431,7 @@ impl RenderOnce for ThreadItem { .child(icon) .child(title_label), ) - .child(gradient_overlay) + .when(self.is_truncated, |this| this.child(gradient_overlay)) .when(self.hovered, |this| { this.when_some(self.action_slot, |this, slot| { let overlay = GradientFade::new(base_bg, hover_bg, hover_bg)