diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 57c799d8fd4f74..108856a26722fe 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -244,6 +244,7 @@ "ctrl-shift-j": "agent::ToggleNavigationMenu", "ctrl-alt-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", + "ctrl-alt-e": "agent::EditTitle", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl->": "agent::AddSelectionToThread", "ctrl-alt-e": "agent::RemoveAllContext", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 9d23eeb8cde071..5219938e01104b 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -283,6 +283,7 @@ "cmd-shift-j": "agent::ToggleNavigationMenu", "cmd-alt-m": "agent::ToggleOptionsMenu", "cmd-alt-shift-n": "agent::ToggleNewThreadMenu", + "ctrl-alt-e": "agent::EditTitle", "shift-alt-escape": "agent::ExpandMessageEditor", "cmd->": "agent::AddSelectionToThread", "cmd-alt-e": "agent::RemoveAllContext", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 3fe5778e5c1219..db00678f4b4fdf 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -245,6 +245,7 @@ "ctrl-shift-j": "agent::ToggleNavigationMenu", "ctrl-alt-i": "agent::ToggleOptionsMenu", // "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", + "ctrl-alt-e": "agent::EditTitle", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl-shift-.": "agent::AddSelectionToThread", "shift-alt-e": "agent::RemoveAllContext", diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 306976473d772f..0614b28c6e789a 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -827,6 +827,16 @@ impl AcpThreadView { } } + pub fn session_id(&self, cx: &App) -> Option { + if let Some(thread) = self.thread() { + Some(thread.read(cx).session_id().clone()) + } else { + self.resume_thread_metadata + .as_ref() + .map(|metadata| metadata.id.clone()) + } + } + pub fn mode_selector(&self) -> Option<&Entity> { match &self.thread_state { ThreadState::Ready { mode_selector, .. } => mode_selector.as_ref(), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 58839d5d8df2a6..9c307d98dcbd20 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,3 +1,4 @@ +use std::cmp::Ordering; use std::ops::Range; use std::path::Path; use std::rc::Rc; @@ -5,11 +6,13 @@ use std::sync::Arc; use acp_thread::AcpThread; use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore}; +use agent_client_protocol as acp; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use project::{ ExternalAgentServerName, agent_server_store::{ - AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME, + AgentServerCommand, AgentServerStore, AllAgentServersSettings, CLAUDE_CODE_NAME, + CODEX_NAME, GEMINI_NAME, }, }; use serde::{Deserialize, Serialize}; @@ -21,7 +24,7 @@ use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent}; use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; use crate::{ - AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, Follow, InlineAssistant, + AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, EditTitle, Follow, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, @@ -46,17 +49,18 @@ use assistant_slash_command::SlashCommandWorkingSet; use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary}; use client::{UserStore, zed_urls}; use cloud_llm_client::{Plan, PlanV1, PlanV2, UsageLimit}; -use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; +use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer, actions::Cancel}; use extension::ExtensionEvents; use extension_host::ExtensionStore; use fs::Fs; use gpui::{ Action, AnyElement, App, AsyncWindowContext, Corner, DismissEvent, Entity, EventEmitter, - ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, Subscription, Task, UpdateGlobal, - WeakEntity, prelude::*, + ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, ScrollHandle, SharedString, + Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, }; use language::LanguageRegistry; use language_model::{ConfigurationError, LanguageModelRegistry}; +use menu::Confirm; use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; @@ -66,7 +70,7 @@ use theme::ThemeSettings; use ui::utils::WithRemSize; use ui::{ Callout, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, - ProgressBar, Tab, Tooltip, prelude::*, + ProgressBar, Tab, TabBar, TabCloseSide, TabPosition, Tooltip, prelude::*, }; use util::ResultExt as _; use workspace::{ @@ -82,6 +86,7 @@ use zed_actions::{ }; const AGENT_PANEL_KEY: &str = "agent_panel"; +const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…"; #[derive(Serialize, Deserialize, Debug)] struct SerializedAgentPanel { @@ -178,6 +183,12 @@ pub fn init(cx: &mut App) { }); } }) + .register_action(|workspace, _: &EditTitle, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + workspace.focus_panel::(window, cx); + panel.update(cx, |panel, cx| panel.focus_title_editor(window, cx)); + } + }) .register_action(|workspace, _: &ToggleNewThreadMenu, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -217,6 +228,7 @@ pub fn init(cx: &mut App) { .detach(); } +type TabId = usize; enum ActiveView { ExternalAgentThread { thread_view: Entity, @@ -231,6 +243,36 @@ enum ActiveView { Configuration, } +struct AgentPanelTab { + view: ActiveView, + agent: AgentType, +} + +impl AgentPanelTab { + fn new(view: ActiveView, agent: AgentType) -> Self { + Self { view, agent } + } + + fn view(&self) -> &ActiveView { + &self.view + } + + fn agent(&self) -> &AgentType { + &self.agent + } +} + +struct TabLabelRender { + element: AnyElement, + tooltip: Option, +} + +#[derive(Clone, PartialEq, Eq)] +enum AgentPanelTabIdentity { + AcpThread(acp::SessionId), + TextThread(Arc), +} + enum WhichFontSize { AgentFont, BufferFont, @@ -439,12 +481,13 @@ pub struct AgentPanel { inline_assist_context_store: Entity, configuration: Option>, configuration_subscription: Option, - active_view: ActiveView, - previous_view: Option, + overlay_view: Option, + overlay_previous_tab_id: Option, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, agent_navigation_menu_handle: PopoverMenuHandle, agent_navigation_menu: Option>, + panel_focus_handle: FocusHandle, _extension_subscription: Option, width: Option, height: Option, @@ -452,6 +495,11 @@ pub struct AgentPanel { pending_serialization: Option>>, onboarding: Entity, selected_agent: AgentType, + pending_tab_removal: Option, + tabs: Vec, + active_tab_id: TabId, + tab_bar_scroll_handle: ScrollHandle, + title_edit_overlay_tab_id: Option, } impl AgentPanel { @@ -518,13 +566,11 @@ impl AgentPanel { if let Some(selected_agent) = serialized_panel.selected_agent { panel.selected_agent = selected_agent.clone(); panel.new_agent_thread(selected_agent, window, cx); + log::info!("Restore the default panel from serialized panel."); + panel.remove_tab_by_id(0, window, cx); } cx.notify(); }); - } else { - panel.update(cx, |panel, cx| { - panel.new_agent_thread(AgentType::NativeAgent, window, cx); - }); } panel.as_mut(cx).loading = false; panel @@ -576,15 +622,18 @@ impl AgentPanel { .detach(); let panel_type = AgentSettings::get_global(cx).default_view; - let active_view = match panel_type { - DefaultView::Thread => ActiveView::native_agent( - fs.clone(), - prompt_store.clone(), - history_store.clone(), - project.clone(), - workspace.clone(), - window, - cx, + let (active_view, selected_agent) = match panel_type { + DefaultView::Thread => ( + ActiveView::native_agent( + fs.clone(), + prompt_store.clone(), + history_store.clone(), + project.clone(), + workspace.clone(), + window, + cx, + ), + AgentType::NativeAgent, ), DefaultView::TextThread => { let context = text_thread_store.update(cx, |store, cx| store.create(cx)); @@ -602,12 +651,15 @@ impl AgentPanel { editor.insert_default_prompt(window, cx); editor }); - ActiveView::text_thread( - text_thread_editor, - history_store.clone(), - language_registry.clone(), - window, - cx, + ( + ActiveView::text_thread( + text_thread_editor, + history_store.clone(), + language_registry.clone(), + window, + cx, + ), + AgentType::TextThread, ) } }; @@ -673,8 +725,18 @@ impl AgentPanel { None }; + let panel_focus_handle = cx.focus_handle(); + cx.on_focus_in(&panel_focus_handle, window, |_, _, cx| { + cx.notify(); + }) + .detach(); + cx.on_focus_out(&panel_focus_handle, window, |_, _, _, cx| { + cx.notify(); + }) + .detach(); + let mut panel = Self { - active_view, + overlay_view: None, workspace, user_store, project: project.clone(), @@ -686,11 +748,12 @@ impl AgentPanel { configuration_subscription: None, context_server_registry, inline_assist_context_store, - previous_view: None, + overlay_previous_tab_id: None, new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu: None, + panel_focus_handle, _extension_subscription: extension_subscription, width: None, height: None, @@ -699,8 +762,13 @@ impl AgentPanel { onboarding, acp_history, history_store, - selected_agent: AgentType::default(), + selected_agent: selected_agent.clone(), loading: false, + pending_tab_removal: None, + tabs: vec![AgentPanelTab::new(active_view, selected_agent)], + active_tab_id: 0, + tab_bar_scroll_handle: ScrollHandle::new(), + title_edit_overlay_tab_id: None, }; // Initial sync of agent servers from extensions @@ -757,13 +825,61 @@ impl AgentPanel { .unwrap_or(true) } + fn active_tab(&self) -> &AgentPanelTab { + self.tabs + .get(self.active_tab_id) + .unwrap_or_else(|| &self.tabs[0]) + } + + fn active_view(&self) -> &ActiveView { + self.overlay_view + .as_ref() + .unwrap_or_else(|| self.active_tab().view()) + } + fn active_thread_view(&self) -> Option<&Entity> { - match &self.active_view { + match self.active_view() { ActiveView::ExternalAgentThread { thread_view, .. } => Some(thread_view), ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, } } + fn view_identity(view: &ActiveView, cx: &mut Context) -> Option { + match view { + ActiveView::ExternalAgentThread { thread_view, .. } => thread_view + .read(cx) + .session_id(cx) + .map(AgentPanelTabIdentity::AcpThread), + ActiveView::TextThread { + text_thread_editor, .. + } => { + let text_thread = { + let editor = text_thread_editor.read(cx); + editor.text_thread().clone() + }; + text_thread + .read(cx) + .path() + .cloned() + .map(AgentPanelTabIdentity::TextThread) + } + ActiveView::History | ActiveView::Configuration => None, + } + } + + fn find_tab_by_identity( + &self, + identity: &AgentPanelTabIdentity, + cx: &mut Context, + ) -> Option { + for (index, tab) in self.tabs.iter().enumerate() { + if Self::view_identity(tab.view(), cx).is_some_and(|existing| existing == *identity) { + return Some(index); + } + } + None + } + fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { self.new_agent_thread(AgentType::NativeAgent, window, cx); } @@ -820,7 +936,7 @@ impl AgentPanel { self.serialize(cx); } - self.set_active_view( + self.push_tab( ActiveView::text_thread( text_thread_editor.clone(), self.history_store.clone(), @@ -828,6 +944,7 @@ impl AgentPanel { window, cx, ), + AgentType::TextThread, window, cx, ); @@ -905,7 +1022,7 @@ impl AgentPanel { this.update_in(cx, |this, window, cx| { let selected_agent = ext_agent.into(); if this.selected_agent != selected_agent { - this.selected_agent = selected_agent; + this.selected_agent = selected_agent.clone(); this.serialize(cx); } @@ -923,7 +1040,12 @@ impl AgentPanel { ) }); - this.set_active_view(ActiveView::ExternalAgentThread { thread_view }, window, cx); + this.push_tab( + ActiveView::ExternalAgentThread { thread_view }, + selected_agent, + window, + cx, + ); }) }) .detach_and_log_err(cx); @@ -963,12 +1085,12 @@ impl AgentPanel { } fn open_history(&mut self, window: &mut Window, cx: &mut Context) { - if matches!(self.active_view, ActiveView::History) { - if let Some(previous_view) = self.previous_view.take() { - self.set_active_view(previous_view, window, cx); + if matches!(self.active_view(), ActiveView::History) { + if let Some(previous_tab_id) = self.overlay_previous_tab_id.take() { + self.set_active_tab_by_id(previous_tab_id, window, cx); } } else { - self.set_active_view(ActiveView::History, window, cx); + self.set_overlay_view(ActiveView::History, window, cx); } cx.notify(); } @@ -1016,7 +1138,7 @@ impl AgentPanel { self.serialize(cx); } - self.set_active_view( + self.push_tab( ActiveView::text_thread( editor, self.history_store.clone(), @@ -1024,30 +1146,25 @@ impl AgentPanel { window, cx, ), + AgentType::TextThread, window, cx, ); } pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context) { - match self.active_view { - ActiveView::Configuration | ActiveView::History => { - if let Some(previous_view) = self.previous_view.take() { - self.active_view = previous_view; + if self.title_edit_overlay_tab_id.take().is_some() { + self.focus_active_panel_thread(window, cx); + return; + } - match &self.active_view { - ActiveView::ExternalAgentThread { thread_view } => { - thread_view.focus_handle(cx).focus(window); - } - ActiveView::TextThread { - text_thread_editor, .. - } => { - text_thread_editor.focus_handle(cx).focus(window); - } - ActiveView::History | ActiveView::Configuration => {} - } + match self.active_view() { + ActiveView::Configuration | ActiveView::History => { + if let Some(previous_tab_id) = self.overlay_previous_tab_id.take() { + self.active_tab_id = previous_tab_id; + self.overlay_view = None; + self.focus_active_panel_thread(window, cx); } - cx.notify(); } _ => {} } @@ -1071,6 +1188,15 @@ impl AgentPanel { self.agent_panel_menu_handle.toggle(window, cx); } + fn edit_active_view_title( + &mut self, + _: &EditTitle, + window: &mut Window, + cx: &mut Context, + ) { + self.focus_title_editor(window, cx); + } + pub fn toggle_new_thread_menu( &mut self, _: &ToggleNewThreadMenu, @@ -1099,7 +1225,7 @@ impl AgentPanel { } fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context) { - match self.active_view.which_font_size_used() { + match self.active_view().which_font_size_used() { WhichFontSize::AgentFont => { if persist { update_settings_file(self.fs.clone(), cx, move |settings, cx| { @@ -1169,7 +1295,7 @@ impl AgentPanel { let context_server_store = self.project.read(cx).context_server_store(); let fs = self.fs.clone(); - self.set_active_view(ActiveView::Configuration, window, cx); + self.set_overlay_view(ActiveView::Configuration, window, cx); self.configuration = Some(cx.new(|cx| { AgentConfiguration::new( fs, @@ -1204,7 +1330,7 @@ impl AgentPanel { return; }; - match &self.active_view { + match self.active_view() { ActiveView::ExternalAgentThread { thread_view } => { thread_view .update(cx, |thread_view, cx| { @@ -1257,7 +1383,7 @@ impl AgentPanel { } pub(crate) fn active_agent_thread(&self, cx: &App) -> Option> { - match &self.active_view { + match self.active_view() { ActiveView::ExternalAgentThread { thread_view, .. } => { thread_view.read(cx).thread().cloned() } @@ -1266,7 +1392,7 @@ impl AgentPanel { } pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option> { - match &self.active_view { + match self.active_view() { ActiveView::ExternalAgentThread { thread_view, .. } => { thread_view.read(cx).as_native_thread(cx) } @@ -1275,7 +1401,7 @@ impl AgentPanel { } pub(crate) fn active_text_thread_editor(&self) -> Option> { - match &self.active_view { + match self.active_view() { ActiveView::TextThread { text_thread_editor, .. } => Some(text_thread_editor.clone()), @@ -1283,45 +1409,41 @@ impl AgentPanel { } } - fn set_active_view( - &mut self, - new_view: ActiveView, - window: &mut Window, - cx: &mut Context, - ) { - let current_is_history = matches!(self.active_view, ActiveView::History); - let new_is_history = matches!(new_view, ActiveView::History); + fn set_active_tab_by_id(&mut self, new_id: TabId, window: &mut Window, cx: &mut Context) { + // TODO: need to check the total items in the list, if it is equal to 1, we should overlay it + let Some((tab_agent, text_thread_editor)) = self.tabs.get(new_id).map(|tab| { + let editor = match tab.view() { + ActiveView::TextThread { + text_thread_editor, .. + } => Some(text_thread_editor.clone()), + _ => None, + }; + (tab.agent().clone(), editor) + }) else { + log::info!("The input new_id is not in the list views!"); + return; + }; - let current_is_config = matches!(self.active_view, ActiveView::Configuration); - let new_is_config = matches!(new_view, ActiveView::Configuration); + self.overlay_view = None; + self.overlay_previous_tab_id = None; + self.title_edit_overlay_tab_id = None; + self.active_tab_id = new_id; + self.tab_bar_scroll_handle.scroll_to_item(new_id); - let current_is_special = current_is_history || current_is_config; - let new_is_special = new_is_history || new_is_config; + if self.selected_agent != tab_agent { + self.selected_agent = tab_agent.clone(); + self.serialize(cx); + } - match &new_view { - ActiveView::TextThread { - text_thread_editor, .. - } => self.history_store.update(cx, |store, cx| { + if let Some(text_thread_editor) = text_thread_editor { + self.history_store.update(cx, |store, cx| { if let Some(path) = text_thread_editor.read(cx).text_thread().read(cx).path() { store.push_recently_opened_entry( agent::HistoryEntryId::TextThread(path.clone()), cx, ) } - }), - ActiveView::ExternalAgentThread { .. } => {} - ActiveView::History | ActiveView::Configuration => {} - } - - if current_is_special && !new_is_special { - self.active_view = new_view; - } else if !current_is_special && new_is_special { - self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view)); - } else { - if !new_is_special { - self.previous_view = None; - } - self.active_view = new_view; + }); } self.focus_handle(cx).focus(window); @@ -1483,11 +1605,157 @@ impl AgentPanel { cx, ); } + + fn set_overlay_view(&mut self, view: ActiveView, window: &mut Window, cx: &mut Context) { + self.title_edit_overlay_tab_id = None; + self.overlay_previous_tab_id = Some(self.active_tab_id); + self.overlay_view = Some(view); + self.focus_handle(cx).focus(window); + } + + fn push_tab( + &mut self, + new_view: ActiveView, + agent: AgentType, + window: &mut Window, + cx: &mut Context, + ) { + let view_identity = Self::view_identity(&new_view, cx); + + if let Some(identity) = view_identity.as_ref() { + if let Some(existing_id) = self.find_tab_by_identity(identity, cx) { + self.set_active_tab_by_id(existing_id, window, cx); + return; + } + } + + match &new_view { + ActiveView::TextThread { .. } | ActiveView::ExternalAgentThread { .. } => { + self.tabs.push(AgentPanelTab::new(new_view, agent)); + let new_id = self.tabs.len() - 1; + self.set_active_tab_by_id(new_id, window, cx); + + if let Some(pending_id) = self.pending_tab_removal.take() { + // Now that we have more than one tab, try removing the deferred one. + if self.tabs.len() > 1 { + self.remove_tab_by_id(pending_id, window, cx); + } else { + self.pending_tab_removal = Some(pending_id); + } + } + } + ActiveView::History | ActiveView::Configuration => { + self.set_overlay_view(new_view, window, cx); + } + } + } + + fn remove_tab_by_id(&mut self, id: TabId, window: &mut Window, cx: &mut Context) { + // Guardrail - ensure we have at least one item in the list + if self.tabs.len() == 1 { + if self.loading && self.tabs.get(id).is_some() { + self.pending_tab_removal = Some(id); + log::info!( + "Deferring removal of tab {id} until another tab is available (panel loading)." + ); + } else { + log::info!("Failed to remove the tab! The tabs list only has one item left."); + } + return; + } + + if self.tabs.get(id).is_some() { + let removed_id = id; + self.tabs.remove(removed_id); + let new_id = if self.active_tab_id == removed_id { + removed_id.min(self.tabs.len() - 1) + } else if self.active_tab_id > removed_id { + self.active_tab_id - 1 + } else { + self.active_tab_id + }; + + if let Some(edit_id) = self.title_edit_overlay_tab_id { + if edit_id == removed_id { + self.title_edit_overlay_tab_id = None; + } else if edit_id > removed_id { + self.title_edit_overlay_tab_id = Some(edit_id - 1); + } + } + + if new_id == self.active_tab_id { + self.tab_bar_scroll_handle.scroll_to_item(new_id); + } else { + self.set_active_tab_by_id(new_id, window, cx); + } + } else { + log::info!("View id is not valid."); + } + } + + fn display_tab_label( + title: impl Into, + is_active: bool, + ) -> (SharedString, Option) { + const MAX_CHARS: usize = 20; + + let title: SharedString = title.into(); + + if is_active || title.chars().count() <= MAX_CHARS { + (title, None) + } else { + let preview: String = title.chars().take(MAX_CHARS).collect(); + (format!("{preview}...").into(), Some(title)) + } + } + + fn focus_active_panel_thread(&self, window: &mut Window, cx: &mut Context) { + match self.active_view() { + ActiveView::ExternalAgentThread { thread_view } => { + thread_view.focus_handle(cx).focus(window); + } + ActiveView::TextThread { + text_thread_editor, .. + } => { + text_thread_editor.focus_handle(cx).focus(window); + } + ActiveView::History | ActiveView::Configuration => {} + } + cx.notify(); + } + + fn focus_title_editor(&mut self, window: &mut Window, cx: &mut Context) { + if self.overlay_view.is_some() + || self.title_edit_overlay_tab_id.is_some() + || !matches!( + self.tabs.get(self.active_tab_id).map(|tab| tab.view()), + Some(ActiveView::ExternalAgentThread { .. } | ActiveView::TextThread { .. }) + ) + { + return; + } + + self.title_edit_overlay_tab_id = Some(self.active_tab_id); + if let Some(tab) = self.tabs.get(self.active_tab_id) { + match tab.view() { + ActiveView::ExternalAgentThread { thread_view } => { + if let Some(editor) = thread_view.read(cx).title_editor() { + editor.focus_handle(cx).focus(window); + } + } + ActiveView::TextThread { title_editor, .. } => { + title_editor.focus_handle(cx).focus(window); + } + ActiveView::History | ActiveView::Configuration => {} + } + } + cx.notify(); + } } impl Focusable for AgentPanel { fn focus_handle(&self, cx: &App) -> FocusHandle { - match &self.active_view { + match self.active_view() { ActiveView::ExternalAgentThread { thread_view, .. } => thread_view.focus_handle(cx), ActiveView::History => self.acp_history.focus_handle(cx), ActiveView::TextThread { @@ -1592,36 +1860,114 @@ impl Panel for AgentPanel { } impl AgentPanel { - fn render_title_view(&self, _window: &mut Window, cx: &Context) -> AnyElement { - const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…"; - - let content = match &self.active_view { + fn render_tab_label( + &self, + view: &ActiveView, + is_active: bool, + cx: &mut Context, + ) -> TabLabelRender { + match view { ActiveView::ExternalAgentThread { thread_view } => { - if let Some(title_editor) = thread_view.read(cx).title_editor() { - div() - .w_full() - .on_action({ - let thread_view = thread_view.downgrade(); - move |_: &menu::Confirm, window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window); - } + let text = thread_view + .read(cx) + .title_editor() + .as_ref() + .map(|editor| editor.read(cx).text(cx)) + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| thread_view.read(cx).title(cx).to_string().into()); + + let (label_text, tooltip) = Self::display_tab_label(text, is_active); + + TabLabelRender { + element: Label::new(label_text) + .color(Color::Muted) + .truncate() + .into_any_element(), + tooltip, + } + } + ActiveView::TextThread { + title_editor, + text_thread_editor, + .. + } => { + let summary = text_thread_editor.read(cx).text_thread().read(cx).summary(); + + match summary { + TextThreadSummary::Pending => TabLabelRender { + element: Label::new(TextThreadSummary::DEFAULT) + .color(Color::Muted) + .truncate() + .into_any_element(), + tooltip: None, + }, + TextThreadSummary::Content(summary) => { + if summary.done { + let mut text = title_editor.read(cx).text(cx); + if text.is_empty() { + text = summary.text.clone().into(); } - }) - .on_action({ - let thread_view = thread_view.downgrade(); - move |_: &editor::actions::Cancel, window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window); - } + let (label_text, tooltip) = Self::display_tab_label(text, is_active); + + TabLabelRender { + element: Label::new(label_text).truncate().into_any_element(), + tooltip, } - }) - .child(title_editor) + } else { + TabLabelRender { + element: Label::new(LOADING_SUMMARY_PLACEHOLDER) + .truncate() + .color(Color::Muted) + .into_any_element(), + tooltip: None, + } + } + } + TextThreadSummary::Error => { + let text = title_editor.read(cx).text(cx); + let (label_text, tooltip) = Self::display_tab_label(text, is_active); + + TabLabelRender { + element: Label::new(label_text) + .color(Color::Muted) + .truncate() + .into_any_element(), + tooltip, + } + } + } + } + ActiveView::History => TabLabelRender { + element: Label::new("History").truncate().into_any_element(), + tooltip: None, + }, + ActiveView::Configuration => TabLabelRender { + element: Label::new("Settings").truncate().into_any_element(), + tooltip: None, + }, + } + } + + fn render_overlay_title_editor(&self, cx: &mut Context) -> Option { + let tab_id = self.title_edit_overlay_tab_id?; + let tab = self.tabs.get(tab_id)?; + let content = match tab.view() { + ActiveView::ExternalAgentThread { thread_view } => { + if let Some(title_editor) = thread_view.read(cx).title_editor() { + h_flex() + .flex_grow() + .items_center() + .child(title_editor.clone()) .into_any_element() } else { - Label::new(thread_view.read(cx).title(cx)) - .color(Color::Muted) - .truncate() + h_flex() + .flex_grow() + .items_center() + .child( + Label::new(thread_view.read(cx).title(cx)) + .color(Color::Muted) + .truncate(), + ) .into_any_element() } } @@ -1633,25 +1979,38 @@ impl AgentPanel { let summary = text_thread_editor.read(cx).text_thread().read(cx).summary(); match summary { - TextThreadSummary::Pending => Label::new(TextThreadSummary::DEFAULT) - .color(Color::Muted) - .truncate() + TextThreadSummary::Pending => h_flex() + .flex_grow() + .items_center() + .child( + Label::new(TextThreadSummary::DEFAULT) + .color(Color::Muted) + .truncate(), + ) .into_any_element(), TextThreadSummary::Content(summary) => { if summary.done { - div() - .w_full() + h_flex() + .flex_grow() + .items_center() .child(title_editor.clone()) .into_any_element() } else { - Label::new(LOADING_SUMMARY_PLACEHOLDER) - .truncate() - .color(Color::Muted) + h_flex() + .flex_grow() + .items_center() + .child( + Label::new(LOADING_SUMMARY_PLACEHOLDER) + .color(Color::Muted) + .truncate(), + ) .into_any_element() } } TextThreadSummary::Error => h_flex() - .w_full() + .flex_grow() + .items_center() + .gap(DynamicSpacing::Base04.rems(cx)) .child(title_editor.clone()) .child( IconButton::new("retry-summary-generation", IconName::RotateCcw) @@ -1659,9 +2018,8 @@ impl AgentPanel { .on_click({ let text_thread_editor = text_thread_editor.clone(); move |_, _window, cx| { - text_thread_editor.update(cx, |text_thread_editor, cx| { - text_thread_editor.regenerate_summary(cx); - }); + text_thread_editor + .update(cx, |editor, cx| editor.regenerate_summary(cx)); } }) .tooltip(move |_window, cx| { @@ -1675,19 +2033,31 @@ impl AgentPanel { .into_any_element(), } } - ActiveView::History => Label::new("History").truncate().into_any_element(), - ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(), + ActiveView::History | ActiveView::Configuration => { + return None; + } }; - h_flex() - .key_context("TitleEditor") - .id("TitleEditor") - .flex_grow() - .w_full() - .max_w_full() - .overflow_x_scroll() - .child(content) - .into_any() + Some( + h_flex() + .flex_grow() + .h(Tab::content_height(cx)) + .px(DynamicSpacing::Base04.px(cx)) + .gap(DynamicSpacing::Base04.rems(cx)) + .child(self.render_toolbar_back_button(cx).into_any_element()) + .child(h_flex().flex_grow().items_center().child(content)) + .on_action(cx.listener(|this, _: &Confirm, window, cx| { + if this.title_edit_overlay_tab_id.take().is_some() { + this.focus_active_panel_thread(window, cx); + } + })) + .on_action(cx.listener(|this, _: &Cancel, window, cx| { + if this.title_edit_overlay_tab_id.take().is_some() { + this.focus_active_panel_thread(window, cx); + } + })) + .into_any_element(), + ) } fn render_panel_options_menu( @@ -1785,6 +2155,7 @@ impl AgentPanel { .action("Profiles", Box::new(ManageProfiles::default())) .action("Settings", Box::new(OpenSettings)) .separator() + .action("Edit Title", Box::new(EditTitle)) .action(full_screen_label, Box::new(ToggleZoom)); if selected_agent == AgentType::Gemini { @@ -1838,6 +2209,13 @@ impl AgentPanel { }) } + fn should_show_tab_bar_controls(&self, window: &Window, cx: &App) -> bool { + self.panel_focus_handle.contains_focused(window, cx) + || self.new_thread_menu_handle.is_focused(window, cx) + || self.agent_navigation_menu_handle.is_focused(window, cx) + || self.agent_panel_menu_handle.is_focused(window, cx) + } + fn render_toolbar_back_button(&self, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); @@ -1853,27 +2231,60 @@ impl AgentPanel { }) } - fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render_tab_agent_icon( + &self, + index: usize, + agent: &AgentType, + agent_server_store: &Entity, + cx: &mut Context, + ) -> AnyElement { + let agent_label = agent.label(); + let tooltip_title = "Selected Agent"; + let agent_custom_icon = if let AgentType::Custom { name, .. } = agent { + agent_server_store + .read(cx) + .agent_icon(&ExternalAgentServerName(name.clone())) + } else { + None + }; + + let has_custom_icon = agent_custom_icon.is_some(); + div() + .id(("agent-tab-agent-icon", index)) + .when_some(agent_custom_icon, |this, icon_path| { + let label = agent_label.clone(); + this.px(DynamicSpacing::Base02.rems(cx)) + .child(Icon::from_path(icon_path).color(Color::Muted)) + .tooltip(move |_window, cx| { + Tooltip::with_meta(label.clone(), None, tooltip_title, cx) + }) + }) + .when(!has_custom_icon, |this| { + this.when_some(agent.icon(), |this, icon| { + let label = agent_label.clone(); + this.px(DynamicSpacing::Base02.rems(cx)) + .child(Icon::new(icon).color(Color::Muted)) + .tooltip(move |_window, cx| { + Tooltip::with_meta(label.clone(), None, tooltip_title, cx) + }) + }) + }) + .into_any_element() + } + + fn render_tab_bar(&self, window: &mut Window, cx: &mut Context) -> AnyElement { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); - // Get custom icon path for selected agent before building menu (to avoid borrow issues) - let selected_agent_custom_icon = - if let AgentType::Custom { name, .. } = &self.selected_agent { - agent_server_store - .read(cx) - .agent_icon(&ExternalAgentServerName(name.clone())) - } else { - None - }; - - let active_thread = match &self.active_view { + let active_thread = match self.active_view() { ActiveView::ExternalAgentThread { thread_view } => { thread_view.read(cx).as_native_thread(cx) } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, }; + let new_thread_menu_store = agent_server_store.clone(); + let show_tab_bar_controls = self.should_show_tab_bar_controls(window, cx); let new_thread_menu = PopoverMenu::new("new_thread_menu") .trigger_with_tooltip( IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small), @@ -1898,6 +2309,7 @@ impl AgentPanel { workspace.project().read(cx).is_via_collab() }) .unwrap_or_default(); + let agent_server_store = new_thread_menu_store.clone(); move |window, cx| { telemetry::event!("New Thread Clicked"); @@ -2150,68 +2562,92 @@ impl AgentPanel { } }); - let selected_agent_label = self.selected_agent.label(); + let end_slot = h_flex() + .gap(DynamicSpacing::Base02.rems(cx)) + .pl(DynamicSpacing::Base04.rems(cx)) + .pr(DynamicSpacing::Base06.rems(cx)) + .child(new_thread_menu) + .child(self.render_recent_entries_menu(IconName::MenuAltTemp, Corner::TopRight, cx)) + .child(self.render_panel_options_menu(window, cx)); - let has_custom_icon = selected_agent_custom_icon.is_some(); - let selected_agent = div() - .id("selected_agent_icon") - .when_some(selected_agent_custom_icon, |this, icon_path| { - let label = selected_agent_label.clone(); - this.px(DynamicSpacing::Base02.rems(cx)) - .child(Icon::from_external_svg(icon_path).color(Color::Muted)) - .tooltip(move |_window, cx| { - Tooltip::with_meta(label.clone(), None, "Selected Agent", cx) - }) - }) - .when(!has_custom_icon, |this| { - this.when_some(self.selected_agent.icon(), |this, icon| { - let label = selected_agent_label.clone(); - this.px(DynamicSpacing::Base02.rems(cx)) - .child(Icon::new(icon).color(Color::Muted)) - .tooltip(move |_window, cx| { - Tooltip::with_meta(label.clone(), None, "Selected Agent", cx) - }) - }) - }) - .into_any_element(); + let mut tab_bar = + TabBar::new("agent-tab-bar").track_scroll(self.tab_bar_scroll_handle.clone()); - h_flex() - .id("agent-panel-toolbar") - .h(Tab::container_height(cx)) - .max_w_full() - .flex_none() - .justify_between() - .gap_2() - .bg(cx.theme().colors().tab_bar_background) - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - h_flex() - .size_full() - .gap(DynamicSpacing::Base04.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) - .child(match &self.active_view { - ActiveView::History | ActiveView::Configuration => { - self.render_toolbar_back_button(cx).into_any_element() - } - _ => selected_agent.into_any_element(), - }) - .child(self.render_title_view(window, cx)), - ) - .child( - h_flex() - .flex_none() - .gap(DynamicSpacing::Base02.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) - .pr(DynamicSpacing::Base06.rems(cx)) - .child(new_thread_menu) - .child(self.render_recent_entries_menu( - IconName::MenuAltTemp, - Corner::TopRight, - cx, - )) - .child(self.render_panel_options_menu(window, cx)), - ) + if show_tab_bar_controls { + tab_bar = tab_bar.end_child(end_slot); + } + + if let Some(overlay_view) = &self.overlay_view { + let TabLabelRender { + element: overlay_label, + .. + } = self.render_tab_label(&overlay_view, true, cx); + + let overlay_title = h_flex() + .flex_grow() + .h(Tab::content_height(cx)) + .px(DynamicSpacing::Base04.px(cx)) + .gap(DynamicSpacing::Base04.rems(cx)) + .child(self.render_toolbar_back_button(cx).into_any_element()) + .child(overlay_label) + .into_any_element(); + + return tab_bar.child(overlay_title).into_any_element(); + } + + if let Some(overlay_editor) = self.render_overlay_title_editor(cx) { + return tab_bar.child(overlay_editor).into_any_element(); + } + + let active_index = self.active_tab_id; + for (index, tab) in self.tabs.iter().enumerate() { + let is_active = index == active_index; + let position = if index == 0 { + TabPosition::First + } else if index == self.tabs.len() - 1 { + TabPosition::Last + } else { + let ordering = if index < active_index { + Ordering::Less + } else if index > active_index { + Ordering::Greater + } else { + Ordering::Equal + }; + TabPosition::Middle(ordering) + }; + + let TabLabelRender { + element: tab_label, + tooltip, + } = self.render_tab_label(tab.view(), is_active, cx); + + let mut tab_component = Tab::new(("agent-tab", index)) + .position(position) + .close_side(TabCloseSide::End) + .toggle_state(is_active) + .on_click(cx.listener(move |this: &mut Self, _, window, cx| { + this.set_active_tab_by_id(index, window, cx); + })) + .child(tab_label) + .start_slot(self.render_tab_agent_icon(index, tab.agent(), &agent_server_store, cx)) + .end_slot( + IconButton::new(("close-agent-tab", index), IconName::Close) + .icon_size(IconSize::Small) + .visible_on_hover("") + .on_click(cx.listener(move |this: &mut Self, _, window, cx| { + this.remove_tab_by_id(index, window, cx); + })) + .tooltip(|_window, cx| cx.new(|_| Tooltip::new("Close Thread")).into()), + ); + + if let Some(tooltip_text) = tooltip { + tab_component = tab_component.tooltip(Tooltip::text(tooltip_text)); + } + tab_bar = tab_bar.child(tab_component); + } + + tab_bar.into_any_element() } fn should_render_trial_end_upsell(&self, cx: &mut Context) -> bool { @@ -2219,7 +2655,7 @@ impl AgentPanel { return false; } - match &self.active_view { + match self.active_view() { ActiveView::TextThread { .. } => { if LanguageModelRegistry::global(cx) .read(cx) @@ -2264,7 +2700,7 @@ impl AgentPanel { return false; } - match &self.active_view { + match self.active_view() { ActiveView::History | ActiveView::Configuration => false, ActiveView::ExternalAgentThread { thread_view, .. } if thread_view.read(cx).as_native_thread(cx).is_none() => @@ -2296,7 +2732,7 @@ impl AgentPanel { return None; } - let text_thread_view = matches!(&self.active_view, ActiveView::TextThread { .. }); + let text_thread_view = matches!(self.active_view(), ActiveView::TextThread { .. }); Some( div() @@ -2418,7 +2854,7 @@ impl AgentPanel { cx: &mut Context, ) -> Div { let mut registrar = buffer_search::DivRegistrar::new( - |this, _, _cx| match &this.active_view { + |this, _, _cx| match this.active_view() { ActiveView::TextThread { buffer_search_bar, .. } => Some(buffer_search_bar.clone()), @@ -2516,7 +2952,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - match &self.active_view { + match self.active_view() { ActiveView::ExternalAgentThread { thread_view } => { thread_view.update(cx, |thread_view, cx| { thread_view.insert_dragged_files(paths, added_worktrees, window, cx); @@ -2542,7 +2978,7 @@ impl AgentPanel { fn key_context(&self) -> KeyContext { let mut key_context = KeyContext::new_with_defaults(); key_context.add("AgentPanel"); - match &self.active_view { + match self.active_view() { ActiveView::ExternalAgentThread { .. } => key_context.add("acp_thread"), ActiveView::TextThread { .. } => key_context.add("text_thread"), ActiveView::History | ActiveView::Configuration => {} @@ -2567,6 +3003,7 @@ impl Render for AgentPanel { .size_full() .justify_between() .key_context(self.key_context()) + .track_focus(&self.panel_focus_handle) .on_action(cx.listener(|this, action: &NewThread, window, cx| { this.new_thread(action, window, cx); })) @@ -2581,6 +3018,7 @@ impl Render for AgentPanel { .on_action(cx.listener(Self::go_back)) .on_action(cx.listener(Self::toggle_navigation_menu)) .on_action(cx.listener(Self::toggle_options_menu)) + .on_action(cx.listener(Self::edit_active_view_title)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) .on_action(cx.listener(Self::reset_font_size)) @@ -2590,9 +3028,9 @@ impl Render for AgentPanel { thread_view.update(cx, |thread_view, cx| thread_view.reauthenticate(window, cx)) } })) - .child(self.render_toolbar(window, cx)) + .child(self.render_tab_bar(window, cx)) .children(self.render_onboarding(window, cx)) - .map(|parent| match &self.active_view { + .map(|parent| match self.active_view() { ActiveView::ExternalAgentThread { thread_view, .. } => parent .child(thread_view.clone()) .child(self.render_drag_target(cx)), @@ -2631,7 +3069,7 @@ impl Render for AgentPanel { }) .children(self.render_trial_end_upsell(window, cx)); - match self.active_view.which_font_size_used() { + match self.active_view().which_font_size_used() { WhichFontSize::AgentFont => { WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx)) .size_full() diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 781374f117d24b..8474d570a80451 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -74,6 +74,8 @@ actions!( ExpandMessageEditor, /// Opens the conversation history view. OpenHistory, + /// Edits the current thread title. + EditTitle, /// Adds a context server to the configuration. AddContextServer, /// Removes the currently selected thread.