From dfc3a7c6e87cdc0465e152f216f2d9c561116af1 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 11 Mar 2026 16:57:22 +0100 Subject: [PATCH 01/12] agent_ui: Move UI logic from `ThreadHistory` to separate component (#51301) This is just a re-factor. We'll make use of this once we make thread history non-global (storing one history per ACP connection). Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 15 +- crates/agent_ui/src/agent_ui.rs | 4 +- crates/agent_ui/src/connection_view.rs | 16 +- .../src/connection_view/thread_view.rs | 2 +- crates/agent_ui/src/entry_view_state.rs | 3 +- crates/agent_ui/src/inline_assistant.rs | 2 +- crates/agent_ui/src/message_editor.rs | 48 +- crates/agent_ui/src/thread_history.rs | 959 +----------------- crates/agent_ui/src/thread_history_view.rs | 878 ++++++++++++++++ 9 files changed, 944 insertions(+), 983 deletions(-) create mode 100644 crates/agent_ui/src/thread_history_view.rs diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 630411c2400ee925f980b5d3a410cb3574e81cd6..1537c05096ec81f1b3f354cac236bfdda52c9f6f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -48,7 +48,7 @@ use crate::{ NewNativeAgentThreadFromSummary, }; use crate::{ - ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent, + ExpandMessageEditor, ThreadHistory, ThreadHistoryView, ThreadHistoryViewEvent, text_thread_history::{TextThreadHistory, TextThreadHistoryEvent}, }; use agent_settings::AgentSettings; @@ -863,6 +863,7 @@ pub struct AgentPanel { fs: Arc, language_registry: Arc, acp_history: Entity, + acp_history_view: Entity, text_thread_history: Entity, thread_store: Entity, text_thread_store: Entity, @@ -1072,14 +1073,15 @@ impl AgentPanel { cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let thread_store = ThreadStore::global(cx); - let acp_history = cx.new(|cx| ThreadHistory::new(None, window, cx)); + let acp_history = cx.new(|cx| ThreadHistory::new(None, cx)); + let acp_history_view = cx.new(|cx| ThreadHistoryView::new(acp_history.clone(), window, cx)); let text_thread_history = cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx)); cx.subscribe_in( - &acp_history, + &acp_history_view, window, |this, _, event, window, cx| match event { - ThreadHistoryEvent::Open(thread) => { + ThreadHistoryViewEvent::Open(thread) => { this.load_agent_thread( thread.session_id.clone(), thread.cwd.clone(), @@ -1213,6 +1215,7 @@ impl AgentPanel { pending_serialization: None, onboarding, acp_history, + acp_history_view, text_thread_history, thread_store, selected_agent: AgentType::default(), @@ -3046,7 +3049,7 @@ impl Focusable for AgentPanel { ActiveView::Uninitialized => self.focus_handle.clone(), ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx), ActiveView::History { kind } => match kind { - HistoryKind::AgentThreads => self.acp_history.focus_handle(cx), + HistoryKind::AgentThreads => self.acp_history_view.focus_handle(cx), HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx), }, ActiveView::TextThread { @@ -4763,7 +4766,7 @@ impl Render for AgentPanel { .child(server_view.clone()) .child(self.render_drag_target(cx)), ActiveView::History { kind } => match kind { - HistoryKind::AgentThreads => parent.child(self.acp_history.clone()), + HistoryKind::AgentThreads => parent.child(self.acp_history_view.clone()), HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()), }, ActiveView::TextThread { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 292db8fc7c0398fdd8c8800b8acc2b3c6df22740..52ce6f0bd7a312966b6602fb43be4074d7f3e620 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -33,6 +33,7 @@ pub mod test_support; mod text_thread_editor; mod text_thread_history; mod thread_history; +mod thread_history_view; mod ui; use std::rc::Rc; @@ -74,7 +75,8 @@ pub(crate) use mode_selector::ModeSelector; pub(crate) use model_selector::ModelSelector; pub(crate) use model_selector_popover::ModelSelectorPopover; pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor}; -pub(crate) use thread_history::*; +pub(crate) use thread_history::ThreadHistory; +pub(crate) use thread_history_view::*; use zed_actions; actions!( diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index b896741cee26e14ed372480f80d6cf8302db180b..b562688a83b75b75a1b95c065b14d0484daef055 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -2901,7 +2901,7 @@ pub(crate) mod tests { let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); // Create history without an initial session list - it will be set after connection - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3007,7 +3007,7 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3066,7 +3066,7 @@ pub(crate) mod tests { let captured_cwd = connection.captured_cwd.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3123,7 +3123,7 @@ pub(crate) mod tests { let captured_cwd = connection.captured_cwd.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3180,7 +3180,7 @@ pub(crate) mod tests { let captured_cwd = connection.captured_cwd.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -3498,7 +3498,7 @@ pub(crate) mod tests { // Set up thread view in workspace 1 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx))); @@ -3718,7 +3718,7 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); @@ -4454,7 +4454,7 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx))); let connection_store = cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index d4d23f5a0a0722afc5c588a355a6a9de1b59d194..44f9e78a2bb47af6cb171194fbd5a34de7383f1b 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -7409,7 +7409,7 @@ impl ThreadView { // TODO: Add keyboard navigation. let is_hovered = self.hovered_recent_history_item == Some(index); - crate::thread_history::HistoryEntryElement::new( + crate::thread_history_view::HistoryEntryElement::new( entry, self.server_view.clone(), ) diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index aef7f1f335eff7d092f924b9883ab0d64bbf65a8..17769335a1cc7e514bad15862d20d4048a089b7b 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -508,8 +508,7 @@ mod tests { }); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let view_state = cx.new(|_cx| { EntryViewState::new( diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 4e7eecfe07aac84269cb1d325cc5a95943578863..2aee2b4601e126b25a977cf92d314970049026da 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -2155,7 +2155,7 @@ pub mod test { }); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = cx.new(|cx| crate::ThreadHistory::new(None, window, cx)); + let history = cx.new(|cx| crate::ThreadHistory::new(None, cx)); // Add editor to workspace workspace.update(cx, |workspace, cx| { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 6c2628f9d37efd0531d5663ac4b1d27d9ae5ae0f..c9067d4ec261261e66c7718b36ebcb96b2099fed 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -1708,8 +1708,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -1822,8 +1821,7 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let workspace_handle = workspace.downgrade(); let message_editor = workspace.update_in(cx, |_, window, cx| { cx.new(|cx| { @@ -1978,8 +1976,7 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![ acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"), @@ -2213,8 +2210,7 @@ mod tests { } let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { @@ -2709,8 +2705,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2810,8 +2805,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let session_id = acp::SessionId::new("thread-123"); let title = Some("Previous Conversation".into()); @@ -2886,8 +2880,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2943,8 +2936,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2998,8 +2990,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -3054,8 +3045,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -3119,8 +3109,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -3279,8 +3268,7 @@ mod tests { }); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); // Create a new `MessageEditor`. The `EditorMode::full()` has to be used // to ensure we have a fixed viewport, so we can eventually actually @@ -3400,8 +3388,7 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -3483,8 +3470,7 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -3568,8 +3554,7 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -3721,8 +3706,7 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs index 01536b00e98d13a699457377a6ebf8e9e87a59b4..5e66d4468767e7002b8b5f6c79ffe8aaecf77127 100644 --- a/crates/agent_ui/src/thread_history.rs +++ b/crates/agent_ui/src/thread_history.rs @@ -1,118 +1,21 @@ -use crate::ConnectionView; -use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread}; use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate}; use agent_client_protocol as acp; -use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; -use editor::{Editor, EditorEvent}; -use fuzzy::StringMatchCandidate; -use gpui::{ - App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, - UniformListScrollHandle, WeakEntity, Window, uniform_list, -}; -use std::{fmt::Display, ops::Range, rc::Rc}; -use text::Bias; -use time::{OffsetDateTime, UtcOffset}; -use ui::{ - ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, - WithScrollbar, prelude::*, -}; - -const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); - -fn thread_title(entry: &AgentSessionInfo) -> &SharedString { - entry - .title - .as_ref() - .filter(|title| !title.is_empty()) - .unwrap_or(DEFAULT_TITLE) -} +use gpui::{App, Task}; +use std::rc::Rc; +use ui::prelude::*; pub struct ThreadHistory { session_list: Option>, sessions: Vec, - scroll_handle: UniformListScrollHandle, - selected_index: usize, - hovered_index: Option, - search_editor: Entity, - search_query: SharedString, - visible_items: Vec, - local_timezone: UtcOffset, - confirming_delete_history: bool, - _visible_items_task: Task<()>, _refresh_task: Task<()>, _watch_task: Option>, - _subscriptions: Vec, -} - -enum ListItemType { - BucketSeparator(TimeBucket), - Entry { - entry: AgentSessionInfo, - format: EntryTimeFormat, - }, - SearchResult { - entry: AgentSessionInfo, - positions: Vec, - }, -} - -impl ListItemType { - fn history_entry(&self) -> Option<&AgentSessionInfo> { - match self { - ListItemType::Entry { entry, .. } => Some(entry), - ListItemType::SearchResult { entry, .. } => Some(entry), - _ => None, - } - } } -pub enum ThreadHistoryEvent { - Open(AgentSessionInfo), -} - -impl EventEmitter for ThreadHistory {} - impl ThreadHistory { - pub fn new( - session_list: Option>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let search_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Search threads...", window, cx); - editor - }); - - let search_editor_subscription = - cx.subscribe(&search_editor, |this, search_editor, event, cx| { - if let EditorEvent::BufferEdited = event { - let query = search_editor.read(cx).text(cx); - if this.search_query != query { - this.search_query = query.into(); - this.update_visible_items(false, cx); - } - } - }); - - let scroll_handle = UniformListScrollHandle::default(); - + pub fn new(session_list: Option>, cx: &mut Context) -> Self { let mut this = Self { session_list: None, sessions: Vec::new(), - scroll_handle, - selected_index: 0, - hovered_index: None, - visible_items: Default::default(), - search_editor, - local_timezone: UtcOffset::from_whole_seconds( - chrono::Local::now().offset().local_minus_utc(), - ) - .unwrap(), - search_query: SharedString::default(), - confirming_delete_history: false, - _subscriptions: vec![search_editor_subscription], - _visible_items_task: Task::ready(()), _refresh_task: Task::ready(()), _watch_task: None, }; @@ -120,43 +23,6 @@ impl ThreadHistory { this } - fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { - let entries = self.sessions.clone(); - let new_list_items = if self.search_query.is_empty() { - self.add_list_separators(entries, cx) - } else { - self.filter_search_results(entries, cx) - }; - let selected_history_entry = if preserve_selected_item { - self.selected_history_entry().cloned() - } else { - None - }; - - self._visible_items_task = cx.spawn(async move |this, cx| { - let new_visible_items = new_list_items.await; - this.update(cx, |this, cx| { - let new_selected_index = if let Some(history_entry) = selected_history_entry { - new_visible_items - .iter() - .position(|visible_entry| { - visible_entry - .history_entry() - .is_some_and(|entry| entry.session_id == history_entry.session_id) - }) - .unwrap_or(0) - } else { - 0 - }; - - this.visible_items = new_visible_items; - this.set_selected_index(new_selected_index, Bias::Right, cx); - cx.notify(); - }) - .ok(); - }); - } - pub fn set_session_list( &mut self, session_list: Option>, @@ -170,9 +36,6 @@ impl ThreadHistory { self.session_list = session_list; self.sessions.clear(); - self.visible_items.clear(); - self.selected_index = 0; - self._visible_items_task = Task::ready(()); self._refresh_task = Task::ready(()); let Some(session_list) = self.session_list.as_ref() else { @@ -181,9 +44,8 @@ impl ThreadHistory { return; }; let Some(rx) = session_list.watch(cx) else { - // No watch support - do a one-time refresh self._watch_task = None; - self.refresh_sessions(false, false, cx); + self.refresh_sessions(false, cx); return; }; session_list.notify_refresh(); @@ -191,7 +53,6 @@ impl ThreadHistory { self._watch_task = Some(cx.spawn(async move |this, cx| { while let Ok(first_update) = rx.recv().await { let mut updates = vec![first_update]; - // Collect any additional updates that are already in the channel while let Ok(update) = rx.try_recv() { updates.push(update); } @@ -202,7 +63,7 @@ impl ThreadHistory { .any(|u| matches!(u, SessionListUpdate::Refresh)); if needs_refresh { - this.refresh_sessions(true, false, cx); + this.refresh_sessions(false, cx); } else { for update in updates { if let SessionListUpdate::SessionInfo { session_id, update } = update { @@ -217,7 +78,7 @@ impl ThreadHistory { } pub(crate) fn refresh_full_history(&mut self, cx: &mut Context) { - self.refresh_sessions(true, true, cx); + self.refresh_sessions(true, cx); } fn apply_info_update( @@ -258,23 +119,15 @@ impl ThreadHistory { session.meta = Some(meta); } - self.update_visible_items(true, cx); + cx.notify(); } - fn refresh_sessions( - &mut self, - preserve_selected_item: bool, - load_all_pages: bool, - cx: &mut Context, - ) { + fn refresh_sessions(&mut self, load_all_pages: bool, cx: &mut Context) { let Some(session_list) = self.session_list.clone() else { - self.update_visible_items(preserve_selected_item, cx); + cx.notify(); return; }; - // If a new refresh arrives while pagination is in progress, the previous - // `_refresh_task` is cancelled. This is intentional (latest refresh wins), - // but means sessions may be in a partial state until the new refresh completes. self._refresh_task = cx.spawn(async move |this, cx| { let mut cursor: Option = None; let mut is_first_page = true; @@ -305,7 +158,7 @@ impl ThreadHistory { } else { this.sessions.extend(page_sessions); } - this.update_visible_items(preserve_selected_item, cx); + cx.notify(); }) .ok(); @@ -378,693 +231,11 @@ impl ThreadHistory { } } - fn add_list_separators( - &self, - entries: Vec, - cx: &App, - ) -> Task> { - cx.background_spawn(async move { - let mut items = Vec::with_capacity(entries.len() + 1); - let mut bucket = None; - let today = Local::now().naive_local().date(); - - for entry in entries.into_iter() { - let entry_bucket = entry - .updated_at - .map(|timestamp| { - let entry_date = timestamp.with_timezone(&Local).naive_local().date(); - TimeBucket::from_dates(today, entry_date) - }) - .unwrap_or(TimeBucket::All); - - if Some(entry_bucket) != bucket { - bucket = Some(entry_bucket); - items.push(ListItemType::BucketSeparator(entry_bucket)); - } - - items.push(ListItemType::Entry { - entry, - format: entry_bucket.into(), - }); - } - items - }) - } - - fn filter_search_results( - &self, - entries: Vec, - cx: &App, - ) -> Task> { - let query = self.search_query.clone(); - cx.background_spawn({ - let executor = cx.background_executor().clone(); - async move { - let mut candidates = Vec::with_capacity(entries.len()); - - for (idx, entry) in entries.iter().enumerate() { - candidates.push(StringMatchCandidate::new(idx, thread_title(entry))); - } - - const MAX_MATCHES: usize = 100; - - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - MAX_MATCHES, - &Default::default(), - executor, - ) - .await; - - matches - .into_iter() - .map(|search_match| ListItemType::SearchResult { - entry: entries[search_match.candidate_id].clone(), - positions: search_match.positions, - }) - .collect() - } - }) - } - - fn search_produced_no_matches(&self) -> bool { - self.visible_items.is_empty() && !self.search_query.is_empty() - } - - fn selected_history_entry(&self) -> Option<&AgentSessionInfo> { - self.get_history_entry(self.selected_index) - } - - fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { - self.visible_items.get(visible_items_ix)?.history_entry() - } - - fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { - if self.visible_items.len() == 0 { - self.selected_index = 0; - return; - } - while matches!( - self.visible_items.get(index), - None | Some(ListItemType::BucketSeparator(..)) - ) { - index = match bias { - Bias::Left => { - if index == 0 { - self.visible_items.len() - 1 - } else { - index - 1 - } - } - Bias::Right => { - if index >= self.visible_items.len() - 1 { - 0 - } else { - index + 1 - } - } - }; - } - self.selected_index = index; - self.scroll_handle - .scroll_to_item(index, ScrollStrategy::Top); - cx.notify() - } - - pub fn select_previous( - &mut self, - _: &menu::SelectPrevious, - _window: &mut Window, - cx: &mut Context, - ) { - if self.selected_index == 0 { - self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); - } else { - self.set_selected_index(self.selected_index - 1, Bias::Left, cx); - } - } - - pub fn select_next( - &mut self, - _: &menu::SelectNext, - _window: &mut Window, - cx: &mut Context, - ) { - if self.selected_index == self.visible_items.len() - 1 { - self.set_selected_index(0, Bias::Right, cx); + pub(crate) fn delete_sessions(&self, cx: &mut App) -> Task> { + if let Some(session_list) = self.session_list.as_ref() { + session_list.delete_sessions(cx) } else { - self.set_selected_index(self.selected_index + 1, Bias::Right, cx); - } - } - - fn select_first( - &mut self, - _: &menu::SelectFirst, - _window: &mut Window, - cx: &mut Context, - ) { - self.set_selected_index(0, Bias::Right, cx); - } - - fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); - } - - fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - self.confirm_entry(self.selected_index, cx); - } - - fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { - let Some(entry) = self.get_history_entry(ix) else { - return; - }; - cx.emit(ThreadHistoryEvent::Open(entry.clone())); - } - - fn remove_selected_thread( - &mut self, - _: &RemoveSelectedThread, - _window: &mut Window, - cx: &mut Context, - ) { - self.remove_thread(self.selected_index, cx) - } - - fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { - let Some(entry) = self.get_history_entry(visible_item_ix) else { - return; - }; - let Some(session_list) = self.session_list.as_ref() else { - return; - }; - if !session_list.supports_delete() { - return; - } - let task = session_list.delete_session(&entry.session_id, cx); - task.detach_and_log_err(cx); - } - - fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(session_list) = self.session_list.as_ref() else { - return; - }; - if !session_list.supports_delete() { - return; - } - session_list.delete_sessions(cx).detach_and_log_err(cx); - self.confirming_delete_history = false; - cx.notify(); - } - - fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = true; - cx.notify(); - } - - fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = false; - cx.notify(); - } - - fn render_list_items( - &mut self, - range: Range, - _window: &mut Window, - cx: &mut Context, - ) -> Vec { - self.visible_items - .get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) - .collect() - } - - fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { - match item { - ListItemType::Entry { entry, format } => self - .render_history_entry(entry, *format, ix, Vec::default(), cx) - .into_any(), - ListItemType::SearchResult { entry, positions } => self.render_history_entry( - entry, - EntryTimeFormat::DateAndTime, - ix, - positions.clone(), - cx, - ), - ListItemType::BucketSeparator(bucket) => div() - .px(DynamicSpacing::Base06.rems(cx)) - .pt_2() - .pb_1() - .child( - Label::new(bucket.to_string()) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - } - } - - fn render_history_entry( - &self, - entry: &AgentSessionInfo, - format: EntryTimeFormat, - ix: usize, - highlight_positions: Vec, - cx: &Context, - ) -> AnyElement { - let selected = ix == self.selected_index; - let hovered = Some(ix) == self.hovered_index; - let entry_time = entry.updated_at; - let display_text = match (format, entry_time) { - (EntryTimeFormat::DateAndTime, Some(entry_time)) => { - let now = Utc::now(); - let duration = now.signed_duration_since(entry_time); - let days = duration.num_days(); - - format!("{}d", days) - } - (EntryTimeFormat::TimeOnly, Some(entry_time)) => { - format.format_timestamp(entry_time.timestamp(), self.local_timezone) - } - (_, None) => "—".to_string(), - }; - - let title = thread_title(entry).clone(); - let full_date = entry_time - .map(|time| { - EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) - }) - .unwrap_or_else(|| "Unknown".to_string()); - - h_flex() - .w_full() - .pb_1() - .child( - ListItem::new(ix) - .rounded() - .toggle_state(selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() - .w_full() - .gap_2() - .justify_between() - .child( - HighlightedLabel::new(thread_title(entry), highlight_positions) - .size(LabelSize::Small) - .truncate(), - ) - .child( - Label::new(display_text) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .tooltip(move |_, cx| { - Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) - }) - .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::(if hovered && self.supports_delete() { - Some( - IconButton::new("delete", IconName::Trash) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(move |_window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, cx) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.remove_thread(ix, cx); - cx.stop_propagation() - })), - ) - } else { - None - }) - .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), - ) - .into_any_element() - } -} - -impl Focusable for ThreadHistory { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.search_editor.focus_handle(cx) - } -} - -impl Render for ThreadHistory { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_no_history = self.is_empty(); - - v_flex() - .key_context("ThreadHistory") - .size_full() - .bg(cx.theme().colors().panel_background) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::select_next)) - .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)) - .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { - this.remove_history(window, cx); - })) - .child( - h_flex() - .h(Tab::container_height(cx)) - .w_full() - .py_1() - .px_2() - .gap_2() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::MagnifyingGlass) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(self.search_editor.clone()), - ) - .child({ - let view = v_flex() - .id("list-container") - .relative() - .overflow_hidden() - .flex_grow(); - - if has_no_history { - view.justify_center().items_center().child( - Label::new("You don't have any past threads yet.") - .size(LabelSize::Small) - .color(Color::Muted), - ) - } else if self.search_produced_no_matches() { - view.justify_center() - .items_center() - .child(Label::new("No threads match your search.").size(LabelSize::Small)) - } else { - view.child( - uniform_list( - "thread-history", - self.visible_items.len(), - cx.processor(|this, range: Range, window, cx| { - this.render_list_items(range, window, cx) - }), - ) - .p_1() - .pr_4() - .track_scroll(&self.scroll_handle) - .flex_grow(), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx) - } - }) - .when(!has_no_history && self.supports_delete(), |this| { - this.child( - h_flex() - .p_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .when(!self.confirming_delete_history, |this| { - this.child( - Button::new("delete_history", "Delete All History") - .full_width() - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.prompt_delete_history(window, cx); - })), - ) - }) - .when(self.confirming_delete_history, |this| { - this.w_full() - .gap_2() - .flex_wrap() - .justify_between() - .child( - h_flex() - .flex_wrap() - .gap_1() - .child( - Label::new("Delete all threads?") - .size(LabelSize::Small), - ) - .child( - Label::new("You won't be able to recover them later.") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("cancel_delete", "Cancel") - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.cancel_delete_history(window, cx); - })), - ) - .child( - Button::new("confirm_delete", "Delete") - .style(ButtonStyle::Tinted(ui::TintColor::Error)) - .color(Color::Error) - .label_size(LabelSize::Small) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action( - Box::new(RemoveHistory), - cx, - ); - })), - ), - ) - }), - ) - }) - } -} - -#[derive(IntoElement)] -pub struct HistoryEntryElement { - entry: AgentSessionInfo, - thread_view: WeakEntity, - selected: bool, - hovered: bool, - supports_delete: bool, - on_hover: Box, -} - -impl HistoryEntryElement { - pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity) -> Self { - Self { - entry, - thread_view, - selected: false, - hovered: false, - supports_delete: false, - on_hover: Box::new(|_, _, _| {}), - } - } - - pub fn supports_delete(mut self, supports_delete: bool) -> Self { - self.supports_delete = supports_delete; - self - } - - pub fn hovered(mut self, hovered: bool) -> Self { - self.hovered = hovered; - self - } - - pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { - self.on_hover = Box::new(on_hover); - self - } -} - -impl RenderOnce for HistoryEntryElement { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let id = ElementId::Name(self.entry.session_id.0.clone().into()); - let title = thread_title(&self.entry).clone(); - let formatted_time = self - .entry - .updated_at - .map(|timestamp| { - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(timestamp); - - if duration.num_days() > 0 { - format!("{}d", duration.num_days()) - } else if duration.num_hours() > 0 { - format!("{}h ago", duration.num_hours()) - } else if duration.num_minutes() > 0 { - format!("{}m ago", duration.num_minutes()) - } else { - "Just now".to_string() - } - }) - .unwrap_or_else(|| "Unknown".to_string()); - - ListItem::new(id) - .rounded() - .toggle_state(self.selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() - .w_full() - .gap_2() - .justify_between() - .child(Label::new(title).size(LabelSize::Small).truncate()) - .child( - Label::new(formatted_time) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .on_hover(self.on_hover) - .end_slot::(if (self.hovered || self.selected) && self.supports_delete { - Some( - IconButton::new("delete", IconName::Trash) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(move |_window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, cx) - }) - .on_click({ - let thread_view = self.thread_view.clone(); - let session_id = self.entry.session_id.clone(); - - move |_event, _window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.update(cx, |thread_view, cx| { - thread_view.delete_history_entry(&session_id, cx); - }); - } - } - }), - ) - } else { - None - }) - .on_click({ - let thread_view = self.thread_view.clone(); - let entry = self.entry; - - move |_event, window, cx| { - if let Some(workspace) = thread_view - .upgrade() - .and_then(|view| view.read(cx).workspace().upgrade()) - { - if let Some(panel) = workspace.read(cx).panel::(cx) { - panel.update(cx, |panel, cx| { - panel.load_agent_thread( - entry.session_id.clone(), - entry.cwd.clone(), - entry.title.clone(), - window, - cx, - ); - }); - } - } - } - }) - } -} - -#[derive(Clone, Copy)] -pub enum EntryTimeFormat { - DateAndTime, - TimeOnly, -} - -impl EntryTimeFormat { - fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { - let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); - - match self { - EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( - timestamp, - OffsetDateTime::now_utc(), - timezone, - time_format::TimestampFormat::EnhancedAbsolute, - ), - EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), - } - } -} - -impl From for EntryTimeFormat { - fn from(bucket: TimeBucket) -> Self { - match bucket { - TimeBucket::Today => EntryTimeFormat::TimeOnly, - TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, - TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, - TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, - TimeBucket::All => EntryTimeFormat::DateAndTime, - } - } -} - -#[derive(PartialEq, Eq, Clone, Copy, Debug)] -enum TimeBucket { - Today, - Yesterday, - ThisWeek, - PastWeek, - All, -} - -impl TimeBucket { - fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { - if date == reference { - return TimeBucket::Today; - } - - if date == reference - TimeDelta::days(1) { - return TimeBucket::Yesterday; - } - - let week = date.iso_week(); - - if reference.iso_week() == week { - return TimeBucket::ThisWeek; - } - - let last_week = (reference - TimeDelta::days(7)).iso_week(); - - if week == last_week { - return TimeBucket::PastWeek; - } - - TimeBucket::All - } -} - -impl Display for TimeBucket { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TimeBucket::Today => write!(f, "Today"), - TimeBucket::Yesterday => write!(f, "Yesterday"), - TimeBucket::ThisWeek => write!(f, "This Week"), - TimeBucket::PastWeek => write!(f, "Past Week"), - TimeBucket::All => write!(f, "All"), + Task::ready(Ok(())) } } } @@ -1073,7 +244,6 @@ impl Display for TimeBucket { mod tests { use super::*; use acp_thread::AgentSessionListResponse; - use chrono::NaiveDate; use gpui::TestAppContext; use std::{ any::Any, @@ -1246,9 +416,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, _cx| { @@ -1270,9 +438,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); session_list.clear_requested_cursors(); @@ -1307,9 +473,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, cx| history.refresh_full_history(cx)); @@ -1340,9 +504,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, cx| history.refresh_full_history(cx)); @@ -1371,9 +533,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, cx| history.refresh_full_history(cx)); @@ -1416,9 +576,7 @@ mod tests { .with_async_responses(), ); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); session_list.clear_requested_cursors(); @@ -1449,19 +607,15 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send a title update session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title("New Title"), }); cx.run_until_parked(); - // Check that the title was updated history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1486,19 +640,15 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an update that clears the title (null) session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title(None::), }); cx.run_until_parked(); - // Check that the title was cleared history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!(session.unwrap().title, None); @@ -1520,19 +670,15 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an update with no fields set (all undefined) session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new(), }); cx.run_until_parked(); - // Check that the title is unchanged history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1557,12 +703,9 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send multiple updates before the executor runs session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title("First Title"), @@ -1573,7 +716,6 @@ mod tests { }); cx.run_until_parked(); - // Check that the final title is "Second Title" (both applied in order) history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1598,12 +740,9 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an info update followed by a refresh session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title("Local Update"), @@ -1611,7 +750,6 @@ mod tests { session_list.send_update(SessionListUpdate::Refresh); cx.run_until_parked(); - // The refresh should have fetched from server, getting "Server Title" history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1636,19 +774,15 @@ mod tests { }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an update for an unknown session session_list.send_update(SessionListUpdate::SessionInfo { session_id: acp::SessionId::new("unknown-session"), update: acp::SessionInfoUpdate::new().title("Should Be Ignored"), }); cx.run_until_parked(); - // Check that the known session is unchanged and no crash occurred history.update(cx, |history, _cx| { assert_eq!(history.sessions.len(), 1); assert_eq!( @@ -1657,43 +791,4 @@ mod tests { ); }); } - - #[test] - fn test_time_bucket_from_dates() { - let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); - - let date = today; - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); - - let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); - - let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); - - // All: not in this week or last week - let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); - - // Test year boundary cases - let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); - - let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); - assert_eq!( - TimeBucket::from_dates(new_year, date), - TimeBucket::Yesterday - ); - - let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); - assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); - } } diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..1756fc46ed48e86dc4bf9c78f2c2ef79618ed43b --- /dev/null +++ b/crates/agent_ui/src/thread_history_view.rs @@ -0,0 +1,878 @@ +use crate::thread_history::ThreadHistory; +use crate::{AgentPanel, ConnectionView, RemoveHistory, RemoveSelectedThread}; +use acp_thread::AgentSessionInfo; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; +use editor::{Editor, EditorEvent}; +use fuzzy::StringMatchCandidate; +use gpui::{ + AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, + UniformListScrollHandle, WeakEntity, Window, uniform_list, +}; +use std::{fmt::Display, ops::Range}; +use text::Bias; +use time::{OffsetDateTime, UtcOffset}; +use ui::{ + ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, + WithScrollbar, prelude::*, +}; + +const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); + +pub(crate) fn thread_title(entry: &AgentSessionInfo) -> &SharedString { + entry + .title + .as_ref() + .filter(|title| !title.is_empty()) + .unwrap_or(DEFAULT_TITLE) +} + +pub struct ThreadHistoryView { + history: Entity, + scroll_handle: UniformListScrollHandle, + selected_index: usize, + hovered_index: Option, + search_editor: Entity, + search_query: SharedString, + visible_items: Vec, + local_timezone: UtcOffset, + confirming_delete_history: bool, + _visible_items_task: Task<()>, + _subscriptions: Vec, +} + +enum ListItemType { + BucketSeparator(TimeBucket), + Entry { + entry: AgentSessionInfo, + format: EntryTimeFormat, + }, + SearchResult { + entry: AgentSessionInfo, + positions: Vec, + }, +} + +impl ListItemType { + fn history_entry(&self) -> Option<&AgentSessionInfo> { + match self { + ListItemType::Entry { entry, .. } => Some(entry), + ListItemType::SearchResult { entry, .. } => Some(entry), + _ => None, + } + } +} + +pub enum ThreadHistoryViewEvent { + Open(AgentSessionInfo), +} + +impl EventEmitter for ThreadHistoryView {} + +impl ThreadHistoryView { + pub fn new( + history: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search threads...", window, cx); + editor + }); + + let search_editor_subscription = + cx.subscribe(&search_editor, |this, search_editor, event, cx| { + if let EditorEvent::BufferEdited = event { + let query = search_editor.read(cx).text(cx); + if this.search_query != query { + this.search_query = query.into(); + this.update_visible_items(false, cx); + } + } + }); + + let history_subscription = cx.observe(&history, |this, _, cx| { + this.update_visible_items(true, cx); + }); + + let scroll_handle = UniformListScrollHandle::default(); + + let mut this = Self { + history, + scroll_handle, + selected_index: 0, + hovered_index: None, + visible_items: Default::default(), + search_editor, + local_timezone: UtcOffset::from_whole_seconds( + chrono::Local::now().offset().local_minus_utc(), + ) + .unwrap(), + search_query: SharedString::default(), + confirming_delete_history: false, + _subscriptions: vec![search_editor_subscription, history_subscription], + _visible_items_task: Task::ready(()), + }; + this.update_visible_items(false, cx); + this + } + + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let entries = self.history.read(cx).sessions().to_vec(); + let new_list_items = if self.search_query.is_empty() { + self.add_list_separators(entries, cx) + } else { + self.filter_search_results(entries, cx) + }; + let selected_history_entry = if preserve_selected_item { + self.selected_history_entry().cloned() + } else { + None + }; + + self._visible_items_task = cx.spawn(async move |this, cx| { + let new_visible_items = new_list_items.await; + this.update(cx, |this, cx| { + let new_selected_index = if let Some(history_entry) = selected_history_entry { + new_visible_items + .iter() + .position(|visible_entry| { + visible_entry + .history_entry() + .is_some_and(|entry| entry.session_id == history_entry.session_id) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.visible_items = new_visible_items; + this.set_selected_index(new_selected_index, Bias::Right, cx); + cx.notify(); + }) + .ok(); + }); + } + + fn add_list_separators( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + cx.background_spawn(async move { + let mut items = Vec::with_capacity(entries.len() + 1); + let mut bucket = None; + let today = Local::now().naive_local().date(); + + for entry in entries.into_iter() { + let entry_bucket = entry + .updated_at + .map(|timestamp| { + let entry_date = timestamp.with_timezone(&Local).naive_local().date(); + TimeBucket::from_dates(today, entry_date) + }) + .unwrap_or(TimeBucket::All); + + if Some(entry_bucket) != bucket { + bucket = Some(entry_bucket); + items.push(ListItemType::BucketSeparator(entry_bucket)); + } + + items.push(ListItemType::Entry { + entry, + format: entry_bucket.into(), + }); + } + items + }) + } + + fn filter_search_results( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + let query = self.search_query.clone(); + cx.background_spawn({ + let executor = cx.background_executor().clone(); + async move { + let mut candidates = Vec::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { + candidates.push(StringMatchCandidate::new(idx, thread_title(entry))); + } + + const MAX_MATCHES: usize = 100; + + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|search_match| ListItemType::SearchResult { + entry: entries[search_match.candidate_id].clone(), + positions: search_match.positions, + }) + .collect() + } + }) + } + + fn search_produced_no_matches(&self) -> bool { + self.visible_items.is_empty() && !self.search_query.is_empty() + } + + fn selected_history_entry(&self) -> Option<&AgentSessionInfo> { + self.get_history_entry(self.selected_index) + } + + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { + self.visible_items.get(visible_items_ix)?.history_entry() + } + + fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { + if self.visible_items.len() == 0 { + self.selected_index = 0; + return; + } + while matches!( + self.visible_items.get(index), + None | Some(ListItemType::BucketSeparator(..)) + ) { + index = match bias { + Bias::Left => { + if index == 0 { + self.visible_items.len() - 1 + } else { + index - 1 + } + } + Bias::Right => { + if index >= self.visible_items.len() - 1 { + 0 + } else { + index + 1 + } + } + }; + } + self.selected_index = index; + self.scroll_handle + .scroll_to_item(index, ScrollStrategy::Top); + cx.notify() + } + + fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == 0 { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } else { + self.set_selected_index(self.selected_index - 1, Bias::Left, cx); + } + } + + fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) { + if self.selected_index == self.visible_items.len() - 1 { + self.set_selected_index(0, Bias::Right, cx); + } else { + self.set_selected_index(self.selected_index + 1, Bias::Right, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + self.set_selected_index(0, Bias::Right, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.confirm_entry(self.selected_index, cx); + } + + fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(ix) else { + return; + }; + cx.emit(ThreadHistoryViewEvent::Open(entry.clone())); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + self.remove_thread(self.selected_index, cx) + } + + fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(visible_item_ix) else { + return; + }; + if !self.history.read(cx).supports_delete() { + return; + } + let session_id = entry.session_id.clone(); + self.history.update(cx, |history, cx| { + history + .delete_session(&session_id, cx) + .detach_and_log_err(cx); + }); + } + + fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { + if !self.history.read(cx).supports_delete() { + return; + } + self.history.update(cx, |history, cx| { + history.delete_sessions(cx).detach_and_log_err(cx); + }); + self.confirming_delete_history = false; + cx.notify(); + } + + fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = true; + cx.notify(); + } + + fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = false; + cx.notify(); + } + + fn render_list_items( + &mut self, + range: Range, + _window: &mut Window, + cx: &mut Context, + ) -> Vec { + self.visible_items + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) + .collect() + } + + fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { + match item { + ListItemType::Entry { entry, format } => self + .render_history_entry(entry, *format, ix, Vec::default(), cx) + .into_any(), + ListItemType::SearchResult { entry, positions } => self.render_history_entry( + entry, + EntryTimeFormat::DateAndTime, + ix, + positions.clone(), + cx, + ), + ListItemType::BucketSeparator(bucket) => div() + .px(DynamicSpacing::Base06.rems(cx)) + .pt_2() + .pb_1() + .child( + Label::new(bucket.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + } + } + + fn render_history_entry( + &self, + entry: &AgentSessionInfo, + format: EntryTimeFormat, + ix: usize, + highlight_positions: Vec, + cx: &Context, + ) -> AnyElement { + let selected = ix == self.selected_index; + let hovered = Some(ix) == self.hovered_index; + let entry_time = entry.updated_at; + let display_text = match (format, entry_time) { + (EntryTimeFormat::DateAndTime, Some(entry_time)) => { + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + let days = duration.num_days(); + + format!("{}d", days) + } + (EntryTimeFormat::TimeOnly, Some(entry_time)) => { + format.format_timestamp(entry_time.timestamp(), self.local_timezone) + } + (_, None) => "—".to_string(), + }; + + let title = thread_title(entry).clone(); + let full_date = entry_time + .map(|time| { + EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) + }) + .unwrap_or_else(|| "Unknown".to_string()); + + let supports_delete = self.history.read(cx).supports_delete(); + + h_flex() + .w_full() + .pb_1() + .child( + ListItem::new(ix) + .rounded() + .toggle_state(selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child( + HighlightedLabel::new(thread_title(entry), highlight_positions) + .size(LabelSize::Small) + .truncate(), + ) + .child( + Label::new(display_text) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) + }) + .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::(if hovered && supports_delete { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_thread(ix, cx); + cx.stop_propagation() + })), + ) + } else { + None + }) + .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), + ) + .into_any_element() + } +} + +impl Focusable for ThreadHistoryView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_editor.focus_handle(cx) + } +} + +impl Render for ThreadHistoryView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_no_history = self.history.read(cx).is_empty(); + let supports_delete = self.history.read(cx).supports_delete(); + + v_flex() + .key_context("ThreadHistory") + .size_full() + .bg(cx.theme().colors().panel_background) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .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)) + .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { + this.remove_history(window, cx); + })) + .child( + h_flex() + .h(Tab::container_height(cx)) + .w_full() + .py_1() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(self.search_editor.clone()), + ) + .child({ + let view = v_flex() + .id("list-container") + .relative() + .overflow_hidden() + .flex_grow(); + + if has_no_history { + view.justify_center().items_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else if self.search_produced_no_matches() { + view.justify_center() + .items_center() + .child(Label::new("No threads match your search.").size(LabelSize::Small)) + } else { + view.child( + uniform_list( + "thread-history", + self.visible_items.len(), + cx.processor(|this, range: Range, window, cx| { + this.render_list_items(range, window, cx) + }), + ) + .p_1() + .pr_4() + .track_scroll(&self.scroll_handle) + .flex_grow(), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + } + }) + .when(!has_no_history && supports_delete, |this| { + this.child( + h_flex() + .p_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when(!self.confirming_delete_history, |this| { + this.child( + Button::new("delete_history", "Delete All History") + .full_width() + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.prompt_delete_history(window, cx); + })), + ) + }) + .when(self.confirming_delete_history, |this| { + this.w_full() + .gap_2() + .flex_wrap() + .justify_between() + .child( + h_flex() + .flex_wrap() + .gap_1() + .child( + Label::new("Delete all threads?") + .size(LabelSize::Small), + ) + .child( + Label::new("You won't be able to recover them later.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("cancel_delete", "Cancel") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_delete_history(window, cx); + })), + ) + .child( + Button::new("confirm_delete", "Delete") + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + Box::new(RemoveHistory), + cx, + ); + })), + ), + ) + }), + ) + }) + } +} + +#[derive(IntoElement)] +pub struct HistoryEntryElement { + entry: AgentSessionInfo, + thread_view: WeakEntity, + selected: bool, + hovered: bool, + supports_delete: bool, + on_hover: Box, +} + +impl HistoryEntryElement { + pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity) -> Self { + Self { + entry, + thread_view, + selected: false, + hovered: false, + supports_delete: false, + on_hover: Box::new(|_, _, _| {}), + } + } + + pub fn supports_delete(mut self, supports_delete: bool) -> Self { + self.supports_delete = supports_delete; + self + } + + pub fn hovered(mut self, hovered: bool) -> Self { + self.hovered = hovered; + self + } + + pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { + self.on_hover = Box::new(on_hover); + self + } +} + +impl RenderOnce for HistoryEntryElement { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let id = ElementId::Name(self.entry.session_id.0.clone().into()); + let title = thread_title(&self.entry).clone(); + let formatted_time = self + .entry + .updated_at + .map(|timestamp| { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(timestamp); + + if duration.num_days() > 0 { + format!("{}d", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h ago", duration.num_hours()) + } else if duration.num_minutes() > 0 { + format!("{}m ago", duration.num_minutes()) + } else { + "Just now".to_string() + } + }) + .unwrap_or_else(|| "Unknown".to_string()); + + ListItem::new(id) + .rounded() + .toggle_state(self.selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child(Label::new(title).size(LabelSize::Small).truncate()) + .child( + Label::new(formatted_time) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .on_hover(self.on_hover) + .end_slot::(if (self.hovered || self.selected) && self.supports_delete { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click({ + let thread_view = self.thread_view.clone(); + let session_id = self.entry.session_id.clone(); + + move |_event, _window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.update(cx, |thread_view, cx| { + thread_view.delete_history_entry(&session_id, cx); + }); + } + } + }), + ) + } else { + None + }) + .on_click({ + let thread_view = self.thread_view.clone(); + let entry = self.entry; + + move |_event, window, cx| { + if let Some(workspace) = thread_view + .upgrade() + .and_then(|view| view.read(cx).workspace().upgrade()) + { + if let Some(panel) = workspace.read(cx).panel::(cx) { + panel.update(cx, |panel, cx| { + panel.load_agent_thread( + entry.session_id.clone(), + entry.cwd.clone(), + entry.title.clone(), + window, + cx, + ); + }); + } + } + } + }) + } +} + +#[derive(Clone, Copy)] +pub enum EntryTimeFormat { + DateAndTime, + TimeOnly, +} + +impl EntryTimeFormat { + fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { + let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); + + match self { + EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( + timestamp, + OffsetDateTime::now_utc(), + timezone, + time_format::TimestampFormat::EnhancedAbsolute, + ), + EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), + } + } +} + +impl From for EntryTimeFormat { + fn from(bucket: TimeBucket) -> Self { + match bucket { + TimeBucket::Today => EntryTimeFormat::TimeOnly, + TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, + TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, + TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, + TimeBucket::All => EntryTimeFormat::DateAndTime, + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + All, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + + let week = date.iso_week(); + + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + + let last_week = (reference - TimeDelta::days(7)).iso_week(); + + if week == last_week { + return TimeBucket::PastWeek; + } + + TimeBucket::All + } +} + +impl Display for TimeBucket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TimeBucket::Today => write!(f, "Today"), + TimeBucket::Yesterday => write!(f, "Yesterday"), + TimeBucket::ThisWeek => write!(f, "This Week"), + TimeBucket::PastWeek => write!(f, "Past Week"), + TimeBucket::All => write!(f, "All"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn test_time_bucket_from_dates() { + let today = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(); + + assert_eq!(TimeBucket::from_dates(today, today), TimeBucket::Today); + + let yesterday = NaiveDate::from_ymd_opt(2025, 1, 14).unwrap(); + assert_eq!( + TimeBucket::from_dates(today, yesterday), + TimeBucket::Yesterday + ); + + let this_week = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap(); + assert_eq!( + TimeBucket::from_dates(today, this_week), + TimeBucket::ThisWeek + ); + + let past_week = NaiveDate::from_ymd_opt(2025, 1, 7).unwrap(); + assert_eq!( + TimeBucket::from_dates(today, past_week), + TimeBucket::PastWeek + ); + + let old = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(); + assert_eq!(TimeBucket::from_dates(today, old), TimeBucket::All); + } +} From 34407d62eaeda4b87c109ad5497328a70553cfae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:31:46 +0100 Subject: [PATCH 02/12] Delete unused workspace dependencies (#51285) Just a small opportunistic cleanup. Release Notes: - N/A --- Cargo.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f650dace84b1b2e6491acf2806077f72000605f5..36e7ca8cc7129af0ed7ab29dc5db338cdf33f7d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -511,7 +511,6 @@ aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] } aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] } backtrace = "0.3" base64 = "0.22" -bincode = "1.2.1" bitflags = "2.6.0" brotli = "8.0.2" bytes = "1.0" @@ -570,7 +569,6 @@ human_bytes = "0.4.1" html5ever = "0.27.0" http = "1.1" http-body = "1.0" -hyper = "0.14" ignore = "0.4.22" image = "0.25.1" imara-diff = "0.1.8" @@ -688,7 +686,6 @@ serde_json_lenient = { version = "0.2", features = [ "raw_value", ] } serde_path_to_error = "0.1.17" -serde_repr = "0.1" serde_urlencoded = "0.7" sha2 = "0.10" shellexpand = "2.1.0" @@ -719,7 +716,6 @@ time = { version = "0.3", features = [ ] } tiny_http = "0.8" tokio = { version = "1" } -tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] } tokio-socks = { version = "0.5.2", default-features = false, features = [ "futures-io", "tokio", From f713373d1026a4e7fa7caf289b33b5009e885e0c Mon Sep 17 00:00:00 2001 From: daydalek <90121301+daydalek@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:06:22 +0800 Subject: [PATCH 03/12] editor: Persist multi-line diagnostic hovers in whitespace areas (#47471) When the mouse cursor moves into the whitespace of a line within a multi-line diagnostic range, the hover popover would previously disappear. This change adds a check to keep the diagnostic hover visible if the mouse row intersects with the active diagnostic's range. Fixes #46841 Release Notes: - Improved hover behavior for multi-line diagnostics to persist when hovering over whitespace. https://github.com/user-attachments/assets/0965cb25-6207-4d4a-9165-0d51157fc6e4 --- crates/editor/src/editor.rs | 1 + crates/editor/src/element.rs | 6 +- crates/editor/src/hover_links.rs | 4 +- crates/editor/src/hover_popover.rs | 174 ++++++++++++++++++++++-- crates/editor/src/inlays/inlay_hints.rs | 5 +- 5 files changed, 173 insertions(+), 17 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ca3dd81ab072d0e20389318515049793a8c827ef..dc2696eb2ca83999934cab6cdee82e364657c70e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -8389,6 +8389,7 @@ impl Editor { self.update_hovered_link( position_map.point_for_position(mouse_position), + Some(mouse_position), &position_map.snapshot, modifiers, window, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 3b1356525960654ea88c6cfa84115f1e67ac2e5b..5de14d80681ca1ad07534e8764217ef75cc90305 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1462,6 +1462,7 @@ impl EditorElement { if text_hovered { editor.update_hovered_link( point_for_position, + Some(event.position), &position_map.snapshot, modifiers, window, @@ -1473,12 +1474,13 @@ impl EditorElement { .snapshot .buffer_snapshot() .anchor_before(point.to_offset(&position_map.snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx); + hover_at(editor, Some(anchor), Some(event.position), window, cx); Self::update_visible_cursor(editor, point, position_map, window, cx); } else { editor.update_inlay_link_and_hover_points( &position_map.snapshot, point_for_position, + Some(event.position), modifiers.secondary(), modifiers.shift, window, @@ -1487,7 +1489,7 @@ impl EditorElement { } } else { editor.hide_hovered_link(cx); - hover_at(editor, None, window, cx); + hover_at(editor, None, Some(event.position), window, cx); } } diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 659a383d6b20129909b4c3f2d7bdbfbe5e580f4e..3a6ff4ec0e4fc53d19bfb51a10b1f7790933b175 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -4,7 +4,7 @@ use crate::{ HighlightKey, Navigated, PointForPosition, SelectPhase, editor_settings::GoToDefinitionFallback, scroll::ScrollAmount, }; -use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Task, Window, px}; +use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Pixels, Task, Window, px}; use language::{Bias, ToOffset}; use linkify::{LinkFinder, LinkKind}; use lsp::LanguageServerId; @@ -113,6 +113,7 @@ impl Editor { pub(crate) fn update_hovered_link( &mut self, point_for_position: PointForPosition, + mouse_position: Option>, snapshot: &EditorSnapshot, modifiers: Modifiers, window: &mut Window, @@ -138,6 +139,7 @@ impl Editor { self.update_inlay_link_and_hover_points( snapshot, point_for_position, + mouse_position, hovered_link_modifier, modifiers.shift, window, diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index f5d5e6d5ab69d690bd5f3aee29bf9aa493cf0059..ad54d6105ca3896d21857d548d80f991a1a76ecc 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -8,10 +8,10 @@ use crate::{ }; use anyhow::Context as _; use gpui::{ - AnyElement, AsyncWindowContext, Context, Entity, Focusable as _, FontWeight, Hsla, - InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size, - StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TextStyleRefinement, - Window, div, px, + AnyElement, App, AsyncApp, AsyncWindowContext, Bounds, Context, Entity, Focusable as _, + FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, + ScrollHandle, Size, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, + TextStyleRefinement, WeakEntity, Window, canvas, div, px, }; use itertools::Itertools; use language::{DiagnosticEntry, Language, LanguageRegistry}; @@ -20,7 +20,10 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint}; use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart}; use settings::Settings; -use std::{borrow::Cow, cell::RefCell}; +use std::{ + borrow::Cow, + cell::{Cell, RefCell}, +}; use std::{ops::Range, sync::Arc, time::Duration}; use std::{path::PathBuf, rc::Rc}; use theme::ThemeSettings; @@ -45,6 +48,7 @@ pub fn hover(editor: &mut Editor, _: &Hover, window: &mut Window, cx: &mut Conte pub fn hover_at( editor: &mut Editor, anchor: Option, + mouse_position: Option>, window: &mut Window, cx: &mut Context, ) { @@ -52,10 +56,37 @@ pub fn hover_at( if show_keyboard_hover(editor, window, cx) { return; } + if let Some(anchor) = anchor { + editor.hover_state.hiding_delay_task = None; + editor.hover_state.closest_mouse_distance = None; show_hover(editor, anchor, false, window, cx); } else { - hide_hover(editor, cx); + let mut getting_closer = false; + if let Some(mouse_position) = mouse_position { + getting_closer = editor.hover_state.is_mouse_getting_closer(mouse_position); + } + + // If we are moving away and a timer is already running, just let it count down. + if !getting_closer && editor.hover_state.hiding_delay_task.is_some() { + return; + } + + // If we are moving closer, or if no timer is running at all, start/restart the 300ms timer. + let delay = 300u64; + let task = cx.spawn(move |this: WeakEntity, cx: &mut AsyncApp| { + let mut cx = cx.clone(); + async move { + cx.background_executor() + .timer(Duration::from_millis(delay)) + .await; + this.update(&mut cx, |editor, cx| { + hide_hover(editor, cx); + }) + .ok(); + } + }); + editor.hover_state.hiding_delay_task = Some(task); } } } @@ -156,6 +187,9 @@ pub fn hover_at_inlay( let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; + editor.hover_state.hiding_delay_task = None; + editor.hover_state.closest_mouse_distance = None; + let task = cx.spawn_in(window, async move |this, cx| { async move { cx.background_executor() @@ -187,6 +221,7 @@ pub fn hover_at_inlay( scroll_handle, keyboard_grace: Rc::new(RefCell::new(false)), anchor: None, + last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }; @@ -216,6 +251,8 @@ pub fn hide_hover(editor: &mut Editor, cx: &mut Context) -> bool { editor.hover_state.info_task = None; editor.hover_state.triggered_from = None; + editor.hover_state.hiding_delay_task = None; + editor.hover_state.closest_mouse_distance = None; editor.clear_background_highlights(HighlightKey::HoverState, cx); @@ -254,6 +291,9 @@ fn show_hover( .map(|project| project.read(cx).languages().clone()); let provider = editor.semantics_provider.clone()?; + editor.hover_state.hiding_delay_task = None; + editor.hover_state.closest_mouse_distance = None; + if !ignore_timeout { if same_info_hover(editor, &snapshot, anchor) || same_diagnostic_hover(editor, &snapshot, anchor) @@ -398,6 +438,7 @@ fn show_hover( background_color, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor, + last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }) } else { @@ -466,6 +507,7 @@ fn show_hover( scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), + last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }) } @@ -507,6 +549,7 @@ fn show_hover( scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), + last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }); } @@ -778,6 +821,8 @@ pub struct HoverState { pub diagnostic_popover: Option, pub triggered_from: Option, pub info_task: Option>>, + pub closest_mouse_distance: Option, + pub hiding_delay_task: Option>, } impl HoverState { @@ -785,6 +830,60 @@ impl HoverState { !self.info_popovers.is_empty() || self.diagnostic_popover.is_some() } + pub fn is_mouse_getting_closer(&mut self, mouse_position: gpui::Point) -> bool { + if !self.visible() { + return false; + } + + let mut popover_bounds = Vec::new(); + for info_popover in &self.info_popovers { + if let Some(bounds) = info_popover.last_bounds.get() { + popover_bounds.push(bounds); + } + } + if let Some(diagnostic_popover) = &self.diagnostic_popover { + if let Some(bounds) = diagnostic_popover.last_bounds.get() { + popover_bounds.push(bounds); + } + } + + if popover_bounds.is_empty() { + return false; + } + + let distance = popover_bounds + .iter() + .map(|bounds| self.distance_from_point_to_bounds(mouse_position, *bounds)) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .unwrap_or(px(f32::MAX)); + + if let Some(closest_distance) = self.closest_mouse_distance { + if distance > closest_distance + px(4.0) { + return false; + } + } + + self.closest_mouse_distance = + Some(distance.min(self.closest_mouse_distance.unwrap_or(distance))); + true + } + + fn distance_from_point_to_bounds( + &self, + point: gpui::Point, + bounds: Bounds, + ) -> Pixels { + let center_x = bounds.origin.x + bounds.size.width / 2.; + let center_y = bounds.origin.y + bounds.size.height / 2.; + let dx: f32 = ((point.x - center_x).abs() - bounds.size.width / 2.) + .max(px(0.0)) + .into(); + let dy: f32 = ((point.y - center_y).abs() - bounds.size.height / 2.) + .max(px(0.0)) + .into(); + px((dx.powi(2) + dy.powi(2)).sqrt()) + } + pub(crate) fn render( &mut self, snapshot: &EditorSnapshot, @@ -887,6 +986,7 @@ pub struct InfoPopover { pub scroll_handle: ScrollHandle, pub keyboard_grace: Rc>, pub anchor: Option, + pub last_bounds: Rc>>>, _subscription: Option, } @@ -898,13 +998,36 @@ impl InfoPopover { cx: &mut Context, ) -> AnyElement { let keyboard_grace = Rc::clone(&self.keyboard_grace); + let this = cx.entity().downgrade(); + let bounds_cell = self.last_bounds.clone(); div() .id("info_popover") .occlude() .elevation_2(cx) + .child( + canvas( + { + move |bounds, _window, _cx| { + bounds_cell.set(Some(bounds)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) // Prevent a mouse down/move on the popover from being propagated to the editor, // because that would dismiss the popover. - .on_mouse_move(|_, _, cx| cx.stop_propagation()) + .on_mouse_move({ + move |_, _, cx: &mut App| { + this.update(cx, |editor, _| { + editor.hover_state.closest_mouse_distance = Some(px(0.0)); + editor.hover_state.hiding_delay_task = None; + }) + .ok(); + cx.stop_propagation() + } + }) .on_mouse_down(MouseButton::Left, move |_, _, cx| { let mut keyboard_grace = keyboard_grace.borrow_mut(); *keyboard_grace = false; @@ -957,6 +1080,7 @@ pub struct DiagnosticPopover { background_color: Hsla, pub keyboard_grace: Rc>, pub anchor: Anchor, + pub last_bounds: Rc>>>, _subscription: Subscription, pub scroll_handle: ScrollHandle, } @@ -970,10 +1094,23 @@ impl DiagnosticPopover { ) -> AnyElement { let keyboard_grace = Rc::clone(&self.keyboard_grace); let this = cx.entity().downgrade(); + let bounds_cell = self.last_bounds.clone(); div() .id("diagnostic") .occlude() .elevation_2_borderless(cx) + .child( + canvas( + { + move |bounds, _window, _cx| { + bounds_cell.set(Some(bounds)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) // Don't draw the background color if the theme // allows transparent surfaces. .when(theme_is_transparent(cx), |this| { @@ -981,7 +1118,17 @@ impl DiagnosticPopover { }) // Prevent a mouse move on the popover from being propagated to the editor, // because that would dismiss the popover. - .on_mouse_move(|_, _, cx| cx.stop_propagation()) + .on_mouse_move({ + let this = this.clone(); + move |_, _, cx: &mut App| { + this.update(cx, |editor, _| { + editor.hover_state.closest_mouse_distance = Some(px(0.0)); + editor.hover_state.hiding_delay_task = None; + }) + .ok(); + cx.stop_propagation() + } + }) // Prevent a mouse down on the popover from being propagated to the editor, // because that would move the cursor. .on_mouse_down(MouseButton::Left, move |_, _, cx| { @@ -1151,7 +1298,7 @@ mod tests { let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx) + hover_at(editor, Some(anchor), None, window, cx) }); assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible())); @@ -1251,7 +1398,7 @@ mod tests { let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx) + hover_at(editor, Some(anchor), None, window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); @@ -1289,7 +1436,7 @@ mod tests { let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx) + hover_at(editor, Some(anchor), None, window, cx) }); assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible())); @@ -1343,7 +1490,7 @@ mod tests { let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx) + hover_at(editor, Some(anchor), None, window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); @@ -1752,6 +1899,7 @@ mod tests { editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), new_type_hint_part_hover_position, + None, true, false, window, @@ -1822,6 +1970,7 @@ mod tests { editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), new_type_hint_part_hover_position, + None, true, false, window, @@ -1877,6 +2026,7 @@ mod tests { editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), struct_hint_part_hover_position, + None, true, false, window, diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index 62eb35f1ac85227c9b52737660da0d1834e1bbfa..414829dc3bbcd89f5f4e4337a955cfff5bb57fca 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -7,7 +7,7 @@ use std::{ use clock::Global; use collections::{HashMap, HashSet}; use futures::future::join_all; -use gpui::{App, Entity, Task}; +use gpui::{App, Entity, Pixels, Task}; use itertools::Itertools; use language::{ BufferRow, @@ -569,6 +569,7 @@ impl Editor { &mut self, snapshot: &EditorSnapshot, point_for_position: PointForPosition, + mouse_position: Option>, secondary_held: bool, shift_held: bool, window: &mut Window, @@ -748,7 +749,7 @@ impl Editor { self.hide_hovered_link(cx) } if !hover_updated { - hover_popover::hover_at(self, None, window, cx); + hover_popover::hover_at(self, None, mouse_position, window, cx); } } From ac2f097559ecbaab6f55ca5a519f53b80ac54afb Mon Sep 17 00:00:00 2001 From: MostlyK <135974627+MostlyKIGuess@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:42:48 +0530 Subject: [PATCH 04/12] image_viewer: Add pinch event support (#47351) This change implements pinch / magnification gesture handling. This uses the following wayland [protocol](https://wayland.app/protocols/pointer-gestures-unstable-v1). And the following [API](https://developer.apple.com/documentation/appkit/nsevent/magnification) for mac. - Original: https://github.com/gpui-ce/gpui-ce/pull/11 Release Notes: - Zooming works with pinching in and out inside Image Viewer --- crates/gpui/src/elements/div.rs | 92 ++++++++++++++++ crates/gpui/src/interactive.rs | 55 ++++++++++ crates/gpui/src/window.rs | 6 ++ crates/gpui_linux/src/linux/wayland/client.rs | 100 ++++++++++++++++++ crates/gpui_macos/src/events.rs | 25 ++++- crates/gpui_macos/src/window.rs | 4 + crates/image_viewer/src/image_viewer.rs | 38 ++++++- 7 files changed, 314 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 3599affc3c792f3c93b3b94cfc44740d7c38caf7..bf185b1b6cc20e0f0f484fd0029c78a6211e6a3a 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -15,6 +15,8 @@ //! and Tailwind-like styling that you can use to build your own custom elements. Div is //! constructed by combining these two systems into an all-in-one element. +#[cfg(any(target_os = "linux", target_os = "macos"))] +use crate::PinchEvent; use crate::{ AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, @@ -353,6 +355,43 @@ impl Interactivity { })); } + /// Bind the given callback to pinch gesture events during the bubble phase. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub fn on_pinch(&mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) { + self.pinch_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx); + } + })); + } + + /// Bind the given callback to pinch gesture events during the capture phase. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub fn capture_pinch( + &mut self, + listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, + ) { + self.pinch_listeners + .push(Box::new(move |event, phase, _hitbox, window, cx| { + if phase == DispatchPhase::Capture { + (listener)(event, window, cx); + } else { + cx.propagate(); + } + })); + } + /// Bind the given callback to an action dispatch during the capture phase. /// The imperative API equivalent to [`InteractiveElement::capture_action`]. /// @@ -635,6 +674,16 @@ impl Interactivity { pub fn block_mouse_except_scroll(&mut self) { self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll; } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn has_pinch_listeners(&self) -> bool { + !self.pinch_listeners.is_empty() + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + fn has_pinch_listeners(&self) -> bool { + false + } } /// A trait for elements that want to use the standard GPUI event handlers that don't @@ -905,6 +954,34 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to pinch gesture events during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_pinch`]. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn on_pinch(mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) -> Self { + self.interactivity().on_pinch(listener); + self + } + + /// Bind the given callback to pinch gesture events during the capture phase. + /// The fluent API equivalent to [`Interactivity::capture_pinch`]. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn capture_pinch( + mut self, + listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().capture_pinch(listener); + self + } /// Capture the given action, before normal action dispatch can fire. /// The fluent API equivalent to [`Interactivity::capture_action`]. /// @@ -1290,6 +1367,10 @@ pub(crate) type MouseMoveListener = pub(crate) type ScrollWheelListener = Box; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) type PinchListener = + Box; + pub(crate) type ClickListener = Rc; pub(crate) type DragListener = @@ -1644,6 +1725,8 @@ pub struct Interactivity { pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) pinch_listeners: Vec, pub(crate) key_down_listeners: Vec, pub(crate) key_up_listeners: Vec, pub(crate) modifiers_changed_listeners: Vec, @@ -1847,6 +1930,7 @@ impl Interactivity { || !self.click_listeners.is_empty() || !self.aux_click_listeners.is_empty() || !self.scroll_wheel_listeners.is_empty() + || self.has_pinch_listeners() || self.drag_listener.is_some() || !self.drop_listeners.is_empty() || self.tooltip_builder.is_some() @@ -2213,6 +2297,14 @@ impl Interactivity { }) } + #[cfg(any(target_os = "linux", target_os = "macos"))] + for listener in self.pinch_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &PinchEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + if self.hover_style.is_some() || self.base_style.mouse_cursor.is_some() || cx.active_drag.is_some() && !self.drag_over_styles.is_empty() diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 5316a5992bb41d11ef5b6518555a9a20795f894c..3d3ddb49f70b2f96772627d085c93ce31b6dc0b5 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -17,6 +17,9 @@ pub trait KeyEvent: InputEvent {} /// A mouse event from the platform. pub trait MouseEvent: InputEvent {} +/// A gesture event from the platform. +pub trait GestureEvent: InputEvent {} + /// The key down event equivalent for the platform. #[derive(Clone, Debug, Eq, PartialEq)] pub struct KeyDownEvent { @@ -467,6 +470,51 @@ impl Default for ScrollDelta { } } +/// A pinch gesture event from the platform, generated when the user performs +/// a pinch-to-zoom gesture (typically on a trackpad). +/// +/// Note: This event is only available on macOS and Wayland (Linux). +/// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. +#[derive(Clone, Debug, Default)] +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub struct PinchEvent { + /// The position of the pinch center on the window. + pub position: Point, + + /// The zoom delta for this event. + /// Positive values indicate zooming in, negative values indicate zooming out. + /// For example, 0.1 represents a 10% zoom increase. + pub delta: f32, + + /// The modifiers that were held down during the pinch gesture. + pub modifiers: Modifiers, + + /// The phase of the pinch gesture. + pub phase: TouchPhase, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Sealed for PinchEvent {} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl InputEvent for PinchEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::Pinch(self) + } +} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl GestureEvent for PinchEvent {} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl MouseEvent for PinchEvent {} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Deref for PinchEvent { + type Target = Modifiers; + + fn deref(&self) -> &Self::Target { + &self.modifiers + } +} + impl ScrollDelta { /// Returns true if this is a precise scroll delta in pixels. pub fn precise(&self) -> bool { @@ -626,6 +674,9 @@ pub enum PlatformInput { MouseExited(MouseExitEvent), /// The scroll wheel was used. ScrollWheel(ScrollWheelEvent), + /// A pinch gesture was performed. + #[cfg(any(target_os = "linux", target_os = "macos"))] + Pinch(PinchEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), } @@ -642,6 +693,8 @@ impl PlatformInput { PlatformInput::MousePressure(event) => Some(event), PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), } } @@ -657,6 +710,8 @@ impl PlatformInput { PlatformInput::MousePressure(_) => None, PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(_) => None, PlatformInput::FileDrop(_) => None, } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 3fcb911d2c58f8968bc6b0c66f26ed2de365dd53..e3c61a4fd31f35df591f20075221907270e352c8 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3945,6 +3945,12 @@ impl Window { self.modifiers = scroll_wheel.modifiers; PlatformInput::ScrollWheel(scroll_wheel) } + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(pinch) => { + self.mouse_position = pinch.position; + self.modifiers = pinch.modifiers; + PlatformInput::Pinch(pinch) + } // Translate dragging and dropping of external files from the operating system // to internal drag and drop events. PlatformInput::FileDrop(file_drop) => match file_drop { diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 8dd48b878cc1ffcb87201e9b1b252966bfce5efb..ce49fca37232f256e570f584272519d8d6f34dd8 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -36,6 +36,9 @@ use wayland_client::{ wl_shm_pool, wl_surface, }, }; +use wayland_protocols::wp::pointer_gestures::zv1::client::{ + zwp_pointer_gesture_pinch_v1, zwp_pointer_gestures_v1, +}; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{ self, ZwpPrimarySelectionOfferV1, }; @@ -124,6 +127,7 @@ pub struct Globals { pub layer_shell: Option, pub blur_manager: Option, pub text_input_manager: Option, + pub gesture_manager: Option, pub dialog: Option, pub executor: ForegroundExecutor, } @@ -164,6 +168,7 @@ impl Globals { layer_shell: globals.bind(&qh, 1..=5, ()).ok(), blur_manager: globals.bind(&qh, 1..=1, ()).ok(), text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), + gesture_manager: globals.bind(&qh, 1..=3, ()).ok(), dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(), executor, qh, @@ -208,6 +213,8 @@ pub(crate) struct WaylandClientState { pub compositor_gpu: Option, wl_seat: wl_seat::WlSeat, // TODO: Multi seat support wl_pointer: Option, + pinch_gesture: Option, + pinch_scale: f32, wl_keyboard: Option, cursor_shape_device: Option, data_device: Option, @@ -584,6 +591,8 @@ impl WaylandClient { wl_seat: seat, wl_pointer: None, wl_keyboard: None, + pinch_gesture: None, + pinch_scale: 1.0, cursor_shape_device: None, data_device, primary_selection, @@ -1325,6 +1334,12 @@ impl Dispatch for WaylandClientStatePtr { .as_ref() .map(|cursor_shape_manager| cursor_shape_manager.get_pointer(&pointer, qh, ())); + state.pinch_gesture = state.globals.gesture_manager.as_ref().map( + |gesture_manager: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1| { + gesture_manager.get_pinch_gesture(&pointer, qh, ()) + }, + ); + if let Some(wl_pointer) = &state.wl_pointer { wl_pointer.release(); } @@ -1998,6 +2013,91 @@ impl Dispatch for WaylandClientStatePtr { } } +impl Dispatch for WaylandClientStatePtr { + fn event( + _this: &mut Self, + _: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + // The gesture manager doesn't generate events + } +} + +impl Dispatch + for WaylandClientStatePtr +{ + fn event( + this: &mut Self, + _: &zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1, + event: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + use gpui::PinchEvent; + + let client = this.get_client(); + let mut state = client.borrow_mut(); + + let Some(window) = state.mouse_focused_window.clone() else { + return; + }; + + match event { + zwp_pointer_gesture_pinch_v1::Event::Begin { + serial: _, + time: _, + surface: _, + fingers: _, + } => { + state.pinch_scale = 1.0; + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: 0.0, + modifiers: state.modifiers, + phase: TouchPhase::Started, + }); + drop(state); + window.handle_input(input); + } + zwp_pointer_gesture_pinch_v1::Event::Update { time: _, scale, .. } => { + let new_absolute_scale = scale as f32; + let previous_scale = state.pinch_scale; + let zoom_delta = new_absolute_scale - previous_scale; + state.pinch_scale = new_absolute_scale; + + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: zoom_delta, + modifiers: state.modifiers, + phase: TouchPhase::Moved, + }); + drop(state); + window.handle_input(input); + } + zwp_pointer_gesture_pinch_v1::Event::End { + serial: _, + time: _, + cancelled: _, + } => { + state.pinch_scale = 1.0; + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: 0.0, + modifiers: state.modifiers, + phase: TouchPhase::Ended, + }); + drop(state); + window.handle_input(input); + } + _ => {} + } + } +} + impl Dispatch for WaylandClientStatePtr { fn event( this: &mut Self, diff --git a/crates/gpui_macos/src/events.rs b/crates/gpui_macos/src/events.rs index 5970488a17fbf9395f4ba29f5b98a135f6d55f7f..71bcb105e8aa8c6c43fd5b7864881535454c5ec3 100644 --- a/crates/gpui_macos/src/events.rs +++ b/crates/gpui_macos/src/events.rs @@ -1,8 +1,8 @@ use gpui::{ Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, - NavigationDirection, Pixels, PlatformInput, PressureStage, ScrollDelta, ScrollWheelEvent, - TouchPhase, point, px, + NavigationDirection, PinchEvent, Pixels, PlatformInput, PressureStage, ScrollDelta, + ScrollWheelEvent, TouchPhase, point, px, }; use crate::{ @@ -234,6 +234,27 @@ pub(crate) unsafe fn platform_input_from_native( _ => None, } } + NSEventType::NSEventTypeMagnify => window_height.map(|window_height| { + let phase = match native_event.phase() { + NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { + TouchPhase::Started + } + NSEventPhase::NSEventPhaseEnded => TouchPhase::Ended, + _ => TouchPhase::Moved, + }; + + let magnification = native_event.magnification() as f32; + + PlatformInput::Pinch(PinchEvent { + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + delta: magnification, + modifiers: read_modifiers(native_event), + phase, + }) + }), NSEventType::NSScrollWheel => window_height.map(|window_height| { let phase = match native_event.phase() { NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 456ee31ac3b03780e68267621d66435b1ceab4a9..c20c86026a102464343fc7c8cfb03b69b19b7641 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -172,6 +172,10 @@ unsafe fn build_classes() { sel!(mouseExited:), handle_view_event as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(magnifyWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); decl.add_method( sel!(mouseDragged:), handle_view_event as extern "C" fn(&Object, Sel, id), diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index c223494bd709217439bdff9f6a7ba17e1a65494e..291603b2b3f1544f6c60f9c3bdbbb87d3f77c424 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -6,6 +6,8 @@ use std::path::Path; use anyhow::Context as _; use editor::{EditorSettings, items::entry_git_aware_label_color}; use file_icons::FileIcons; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use gpui::PinchEvent; use gpui::{ AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, @@ -260,6 +262,12 @@ impl ImageView { cx.notify(); } } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn handle_pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context) { + let zoom_factor = 1.0 + event.delta; + self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx); + } } struct ImageContentElement { @@ -679,8 +687,9 @@ impl Render for ImageView { .size_full() .relative() .bg(cx.theme().colors().editor_background) - .child( - div() + .child({ + #[cfg(any(target_os = "linux", target_os = "macos"))] + let container = div() .id("image-container") .size_full() .overflow_hidden() @@ -690,13 +699,34 @@ impl Render for ImageView { gpui::CursorStyle::OpenHand }) .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) + .on_pinch(cx.listener(Self::handle_pinch)) .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) .on_mouse_move(cx.listener(Self::handle_mouse_move)) - .child(ImageContentElement::new(cx.entity())), - ) + .child(ImageContentElement::new(cx.entity())); + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let container = div() + .id("image-container") + .size_full() + .overflow_hidden() + .cursor(if self.is_dragging() { + gpui::CursorStyle::ClosedHand + } else { + gpui::CursorStyle::OpenHand + }) + .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) + .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) + .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) + .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) + .on_mouse_move(cx.listener(Self::handle_mouse_move)) + .child(ImageContentElement::new(cx.entity())); + + container + }) } } From 480e269097a55b8250b20e1550f3139df6c0d3f1 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:21:28 -0300 Subject: [PATCH 05/12] agent_ui: Add UI refinements to the sidebar (#51307) Release Notes: - N/A --- assets/icons/threads_sidebar_left_closed.svg | 5 + assets/icons/threads_sidebar_left_open.svg | 5 + assets/icons/threads_sidebar_right_closed.svg | 5 + assets/icons/threads_sidebar_right_open.svg | 5 + assets/icons/workspace_nav_closed.svg | 5 - assets/icons/workspace_nav_open.svg | 5 - crates/agent_ui/src/agent_panel.rs | 133 +++++++++++------- crates/agent_ui/src/sidebar.rs | 87 ++++++++---- crates/icons/src/icons.rs | 6 +- crates/ui/src/components/ai/thread_item.rs | 67 +++++---- crates/ui/src/components/list/list_item.rs | 8 ++ 11 files changed, 211 insertions(+), 120 deletions(-) create mode 100644 assets/icons/threads_sidebar_left_closed.svg create mode 100644 assets/icons/threads_sidebar_left_open.svg create mode 100644 assets/icons/threads_sidebar_right_closed.svg create mode 100644 assets/icons/threads_sidebar_right_open.svg delete mode 100644 assets/icons/workspace_nav_closed.svg delete mode 100644 assets/icons/workspace_nav_open.svg diff --git a/assets/icons/threads_sidebar_left_closed.svg b/assets/icons/threads_sidebar_left_closed.svg new file mode 100644 index 0000000000000000000000000000000000000000..feb1015254635ef65f90f2c9ea38efab74d01d60 --- /dev/null +++ b/assets/icons/threads_sidebar_left_closed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/threads_sidebar_left_open.svg b/assets/icons/threads_sidebar_left_open.svg new file mode 100644 index 0000000000000000000000000000000000000000..8057b060a84d7d7ffcf29aff1c0c79a8764edc22 --- /dev/null +++ b/assets/icons/threads_sidebar_left_open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/threads_sidebar_right_closed.svg b/assets/icons/threads_sidebar_right_closed.svg new file mode 100644 index 0000000000000000000000000000000000000000..10fa4b792fd65b5875dcf2cadab1fc12a123ab47 --- /dev/null +++ b/assets/icons/threads_sidebar_right_closed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/threads_sidebar_right_open.svg b/assets/icons/threads_sidebar_right_open.svg new file mode 100644 index 0000000000000000000000000000000000000000..23a01eb3f82a5866157220172c868ed9ded46033 --- /dev/null +++ b/assets/icons/threads_sidebar_right_open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/workspace_nav_closed.svg b/assets/icons/workspace_nav_closed.svg deleted file mode 100644 index ed1fce52d6826a4d10299f331358ff84e4caa973..0000000000000000000000000000000000000000 --- a/assets/icons/workspace_nav_closed.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/workspace_nav_open.svg b/assets/icons/workspace_nav_open.svg deleted file mode 100644 index 464b6aac73c2aeaa9463a805aabc4559377bbfd3..0000000000000000000000000000000000000000 --- a/assets/icons/workspace_nav_open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 1537c05096ec81f1b3f354cac236bfdda52c9f6f..50346bd752cec4432fb5a87e4df7cb4ce09aca83 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -483,9 +483,17 @@ pub fn init(cx: &mut App) { } if let Some(panel) = workspace.panel::(cx) { if let Some(sidebar) = panel.read(cx).sidebar.clone() { + let was_open = sidebar.read(cx).is_open(); sidebar.update(cx, |sidebar, cx| { sidebar.toggle(window, cx); }); + // When closing the sidebar, restore focus to the active pane + // to avoid "zombie focus" on the now-hidden sidebar elements + if was_open { + let active_pane = workspace.active_pane().clone(); + let pane_focus = active_pane.read(cx).focus_handle(cx); + window.focus(&pane_focus, cx); + } } } }) @@ -3623,7 +3631,7 @@ impl AgentPanel { Some((view, width, is_open)) } - fn render_sidebar_toggle(&self, cx: &Context) -> Option { + fn render_sidebar_toggle(&self, docked_right: bool, cx: &Context) -> Option { if !multi_workspace_enabled(cx) { return None; } @@ -3634,20 +3642,41 @@ impl AgentPanel { } let has_notifications = sidebar_read.has_notifications(cx); + let icon = if docked_right { + IconName::ThreadsSidebarRightClosed + } else { + IconName::ThreadsSidebarLeftClosed + }; + Some( - IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed) - .icon_size(IconSize::Small) - .when(has_notifications, |button| { - button - .indicator(Indicator::dot().color(Color::Accent)) - .indicator_border_color(Some(cx.theme().colors().tab_bar_background)) - }) - .tooltip(move |_, cx| { - Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) - }) - .on_click(|_, window, cx| { - window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + h_flex() + .h_full() + .px_1() + .map(|this| { + if docked_right { + this.border_l_1() + } else { + this.border_r_1() + } }) + .border_color(cx.theme().colors().border_variant) + .child( + IconButton::new("toggle-workspace-sidebar", icon) + .icon_size(IconSize::Small) + .when(has_notifications, |button| { + button + .indicator(Indicator::dot().color(Color::Accent)) + .indicator_border_color(Some( + cx.theme().colors().tab_bar_background, + )) + }) + .tooltip(move |_, cx| { + Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + }), + ) .into_any_element(), ) } @@ -4104,6 +4133,23 @@ impl AgentPanel { let use_v2_empty_toolbar = has_v2_flag && is_empty_state && !is_in_history_or_config; + let is_sidebar_open = self + .sidebar + .as_ref() + .map(|s| s.read(cx).is_open()) + .unwrap_or(false); + + let base_container = 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); + if use_v2_empty_toolbar { let (chevron_icon, icon_color, label_color) = if self.new_thread_menu_handle.is_deployed() { @@ -4162,34 +4208,26 @@ impl AgentPanel { y: px(1.0), }); - 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) + base_container .child( h_flex() .size_full() - .gap(DynamicSpacing::Base04.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) + .gap_1() + .when(is_sidebar_open || docked_right, |this| this.pl_1()) .when(!docked_right, |this| { - this.children(self.render_sidebar_toggle(cx)) + this.children(self.render_sidebar_toggle(false, cx)) }) .child(agent_selector_menu) .child(self.render_start_thread_in_selector(cx)), ) .child( h_flex() + .h_full() .flex_none() - .gap(DynamicSpacing::Base02.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) - .pr(DynamicSpacing::Base06.rems(cx)) - .when(show_history_menu, |this| { + .gap_1() + .pl_1() + .pr_1() + .when(show_history_menu && !has_v2_flag, |this| { this.child(self.render_recent_entries_menu( IconName::MenuAltTemp, Corner::TopRight, @@ -4198,7 +4236,7 @@ impl AgentPanel { }) .child(self.render_panel_options_menu(window, cx)) .when(docked_right, |this| { - this.children(self.render_sidebar_toggle(cx)) + this.children(self.render_sidebar_toggle(true, cx)) }), ) .into_any_element() @@ -4222,23 +4260,19 @@ impl AgentPanel { .with_handle(self.new_thread_menu_handle.clone()) .menu(move |window, cx| new_thread_menu_builder(window, cx)); - 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) + base_container .child( h_flex() .size_full() - .gap(DynamicSpacing::Base04.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) + .map(|this| { + if is_sidebar_open || docked_right { + this.pl_1().gap_1() + } else { + this.pl_0().gap_0p5() + } + }) .when(!docked_right, |this| { - this.children(self.render_sidebar_toggle(cx)) + this.children(self.render_sidebar_toggle(false, cx)) }) .child(match &self.active_view { ActiveView::History { .. } | ActiveView::Configuration => { @@ -4250,12 +4284,13 @@ impl AgentPanel { ) .child( h_flex() + .h_full() .flex_none() - .gap(DynamicSpacing::Base02.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) - .pr(DynamicSpacing::Base06.rems(cx)) + .gap_1() + .pl_1() + .pr_1() .child(new_thread_menu) - .when(show_history_menu, |this| { + .when(show_history_menu && !has_v2_flag, |this| { this.child(self.render_recent_entries_menu( IconName::MenuAltTemp, Corner::TopRight, @@ -4264,7 +4299,7 @@ impl AgentPanel { }) .child(self.render_panel_options_menu(window, cx)) .when(docked_right, |this| { - this.children(self.render_sidebar_toggle(cx)) + this.children(self.render_sidebar_toggle(true, cx)) }), ) .into_any_element() diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index ae3a4f0ccb9df6073ae24a9c482b6c56de0ea968..e36cb750b4a74dc8d749501eed07941cd30c7b6f 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -713,6 +713,8 @@ impl Sidebar { let is_group_header_after_first = ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. }); + let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right; + let rendered = match entry { ListEntry::ProjectHeader { path_list, @@ -728,9 +730,12 @@ impl Sidebar { highlight_positions, *has_threads, is_selected, + docked_right, cx, ), - ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx), + ListEntry::Thread(thread) => { + self.render_thread(ix, thread, is_selected, docked_right, cx) + } ListEntry::ViewMore { path_list, remaining_count, @@ -770,6 +775,7 @@ impl Sidebar { highlight_positions: &[usize], has_threads: bool, is_selected: bool, + docked_right: bool, cx: &mut Context, ) -> AnyElement { let id = SharedString::from(format!("project-header-{}", ix)); @@ -815,12 +821,13 @@ impl Sidebar { .group_name(group_name) .toggle_state(is_active_workspace) .focused(is_selected) + .docked_right(docked_right) .child( h_flex() .relative() .min_w_0() .w_full() - .p_1() + .py_1() .gap_1p5() .child( Icon::new(disclosure_icon) @@ -969,7 +976,7 @@ impl Sidebar { } fn has_filter_query(&self, cx: &App) -> bool { - self.filter_editor.read(cx).buffer().read(cx).is_empty() + !self.filter_editor.read(cx).text(cx).is_empty() } fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { @@ -1156,6 +1163,7 @@ impl Sidebar { ix: usize, thread: &ThreadEntry, is_selected: bool, + docked_right: bool, cx: &mut Context, ) -> AnyElement { let has_notification = self @@ -1171,6 +1179,7 @@ impl Sidebar { let workspace = thread.workspace.clone(); let id = SharedString::from(format!("thread-entry-{}", ix)); + ThreadItem::new(id, title) .icon(thread.icon) .when_some(thread.icon_from_external_svg.clone(), |this, svg| { @@ -1187,6 +1196,7 @@ impl Sidebar { }) .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) .focused(is_selected) + .docked_right(docked_right) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; this.activate_thread(session_info.clone(), &workspace, window, cx); @@ -1301,6 +1311,7 @@ impl Sidebar { div() .w_full() .p_2() + .pt_1p5() .child( Button::new( SharedString::from(format!("new-thread-btn-{}", ix)), @@ -1320,6 +1331,40 @@ impl Sidebar { ) .into_any_element() } + + fn render_sidebar_toggle_button( + &self, + docked_right: bool, + cx: &mut Context, + ) -> impl IntoElement { + let icon = if docked_right { + IconName::ThreadsSidebarRightOpen + } else { + IconName::ThreadsSidebarLeftOpen + }; + + h_flex() + .h_full() + .px_1() + .map(|this| { + if docked_right { + this.pr_1p5().border_l_1() + } else { + this.border_r_1() + } + }) + .border_color(cx.theme().colors().border_variant) + .child( + IconButton::new("sidebar-close-toggle", icon) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + }), + ) + } } impl Sidebar { @@ -1416,37 +1461,19 @@ impl Render for Sidebar { .child({ let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right; - let render_close_button = || { - IconButton::new("sidebar-close-toggle", IconName::WorkspaceNavOpen) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| { - Tooltip::for_action( - "Close Threads Sidebar", - &ToggleWorkspaceSidebar, - cx, - ) - }) - .on_click(|_, window, cx| { - window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); - }) - }; h_flex() - .flex_none() - .px_2p5() .h(Tab::container_height(cx)) - .gap_2() + .flex_none() + .gap_1p5() .border_b_1() .border_color(cx.theme().colors().border) - .when(!docked_right, |this| this.child(render_close_button())) - .child( - Icon::new(IconName::MagnifyingGlass) - .size(IconSize::Small) - .color(Color::Muted), - ) + .when(!docked_right, |this| { + this.child(self.render_sidebar_toggle_button(false, cx)) + }) .child(self.render_filter_input(cx)) .when(has_query, |this| { - this.pr_1().child( + this.when(!docked_right, |this| this.pr_1p5()).child( IconButton::new("clear_filter", IconName::Close) .shape(IconButtonShape::Square) .tooltip(Tooltip::text("Clear Search")) @@ -1456,7 +1483,11 @@ impl Render for Sidebar { })), ) }) - .when(docked_right, |this| this.child(render_close_button())) + .when(docked_right, |this| { + this.pl_2() + .pr_0p5() + .child(self.render_sidebar_toggle_button(true, cx)) + }) }) .child( v_flex() diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 7c06eaef92ece60e8b4a9ad78976b68aee854226..94fed7f03f46e64ef0ac929e60cf6ae848145e72 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -244,6 +244,10 @@ pub enum IconName { ThinkingModeOff, Thread, ThreadFromSummary, + ThreadsSidebarLeftClosed, + ThreadsSidebarLeftOpen, + ThreadsSidebarRightClosed, + ThreadsSidebarRightOpen, ThumbsDown, ThumbsUp, TodoComplete, @@ -272,8 +276,6 @@ pub enum IconName { UserRoundPen, Warning, WholeWord, - WorkspaceNavClosed, - WorkspaceNavOpen, XCircle, XCircleFilled, ZedAgent, diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index edc685159f5c9edc5fa872e9d453d0b81fa9cb16..1ab516b0cbbcb20c98bf61525779d2bd760ef260 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -1,6 +1,6 @@ use crate::{ - DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration, IconDecorationKind, - SpinnerLabel, prelude::*, + CommonAnimationExt, DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration, + IconDecorationKind, prelude::*, }; use gpui::{AnyView, ClickEvent, Hsla, SharedString}; @@ -26,6 +26,7 @@ pub struct ThreadItem { selected: bool, focused: bool, hovered: bool, + docked_right: bool, added: Option, removed: Option, worktree: Option, @@ -50,6 +51,7 @@ impl ThreadItem { selected: false, focused: false, hovered: false, + docked_right: false, added: None, removed: None, worktree: None, @@ -107,6 +109,11 @@ impl ThreadItem { self } + pub fn docked_right(mut self, docked_right: bool) -> Self { + self.docked_right = docked_right; + self + } + pub fn worktree(mut self, worktree: impl Into) -> Self { self.worktree = Some(worktree.into()); self @@ -154,12 +161,12 @@ impl ThreadItem { impl RenderOnce for ThreadItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let color = cx.theme().colors(); - // let dot_separator = || { - // Label::new("•") - // .size(LabelSize::Small) - // .color(Color::Muted) - // .alpha(0.5) - // }; + let dot_separator = || { + Label::new("•") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5) + }; let icon_container = || h_flex().size_4().flex_none().justify_center(); let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg { @@ -194,17 +201,23 @@ impl RenderOnce for ThreadItem { None }; - let icon = if let Some(decoration) = decoration { - icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration))) - } else { - icon_container().child(agent_icon) - }; - let is_running = matches!( self.status, AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation ); - let running_or_action = is_running || (self.hovered && self.action_slot.is_some()); + + let icon = if is_running { + icon_container().child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + } else if let Some(decoration) = decoration { + icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration))) + } else { + icon_container().child(agent_icon) + }; let title = self.title; let highlight_positions = self.highlight_positions; @@ -244,13 +257,16 @@ impl RenderOnce for ThreadItem { if has_worktree || has_diff_stats { this.p_2() } else { - this.px_2().py_1() + this.p_1() } }) .when(self.selected, |s| s.bg(color.element_active)) .border_1() .border_color(gpui::transparent_black()) - .when(self.focused, |s| s.border_color(color.panel_focused_border)) + .when(self.focused, |s| { + s.when(self.docked_right, |s| s.border_r_2()) + .border_color(color.border_focused) + }) .hover(|s| s.bg(color.element_hover)) .on_hover(self.on_hover) .child( @@ -270,20 +286,8 @@ impl RenderOnce for ThreadItem { .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)), ) .child(gradient_overlay) - .when(running_or_action, |this| { - this.child( - h_flex() - .gap_1() - .when(is_running, |this| { - this.child( - icon_container() - .child(SpinnerLabel::new().color(Color::Accent)), - ) - }) - .when(self.hovered, |this| { - this.when_some(self.action_slot, |this, slot| this.child(slot)) - }), - ) + .when(self.hovered, |this| { + this.when_some(self.action_slot, |this, slot| this.child(slot)) }), ) .when_some(self.worktree, |this, worktree| { @@ -306,6 +310,7 @@ impl RenderOnce for ThreadItem { .gap_1p5() .child(icon_container()) // Icon Spacing .child(worktree_label) + .child(dot_separator()) .when(has_diff_stats, |this| { this.child(DiffStat::new( diff_stat_id.clone(), diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 01e88e1fe666fa2038b05af055a0e02b195e9bac..d707df82f4d19b0a3f519e9d6ac9ccdb22965e27 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -48,6 +48,7 @@ pub struct ListItem { rounded: bool, overflow_x: bool, focused: Option, + docked_right: bool, } impl ListItem { @@ -78,6 +79,7 @@ impl ListItem { rounded: false, overflow_x: false, focused: None, + docked_right: false, } } @@ -194,6 +196,11 @@ impl ListItem { self.focused = Some(focused); self } + + pub fn docked_right(mut self, docked_right: bool) -> Self { + self.docked_right = docked_right; + self + } } impl Disableable for ListItem { @@ -247,6 +254,7 @@ impl RenderOnce for ListItem { this.when_some(self.focused, |this, focused| { if focused { this.border_1() + .when(self.docked_right, |this| this.border_r_2()) .border_color(cx.theme().colors().border_focused) } else { this.border_1() From 45072109221790776b03b686b6973dd7f8227cb0 Mon Sep 17 00:00:00 2001 From: Jack Date: Thu, 12 Mar 2026 01:55:15 +0800 Subject: [PATCH 06/12] languages: Exclude angle brackets from rainbow bracket colorization for TSX (#51311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Angle brackets in TSX (`<`, `>`, `/>`, ` Screenshots: I don't have a built copy of Zed handy to attach — happy to add one if a maintainer needs it before merging. Release Notes: - Removed rainbow bracket colorization for angled brackets within TSX. --- crates/languages/src/tsx/brackets.scm | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/languages/src/tsx/brackets.scm b/crates/languages/src/tsx/brackets.scm index d72fcb26005a0021907558bbbee7471cfeaec603..cd59d553783f685775e45ba883210272b168c3b8 100644 --- a/crates/languages/src/tsx/brackets.scm +++ b/crates/languages/src/tsx/brackets.scm @@ -7,14 +7,17 @@ ("{" @open "}" @close) -("<" @open +(("<" @open ">" @close) + (#set! rainbow.exclude)) -("<" @open +(("<" @open "/>" @close) + (#set! rainbow.exclude)) -("" @close) + (#set! rainbow.exclude)) (("\"" @open "\"" @close) From 546dacc29bf9edd75cff4083ba7ac7d203947fb3 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Wed, 11 Mar 2026 19:48:12 +0100 Subject: [PATCH 07/12] nix: Correctly handle commitSha == null in nix devshell (#51319) Release Notes: - N/A --- nix/build.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/build.nix b/nix/build.nix index d96a7e51ca08d23572b01f0c387d6ef9e4f2dd70..a5ced61bbbfd145c1e3f9fc9909ae69779ba133a 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -224,7 +224,7 @@ let }; ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled."; RELEASE_VERSION = version; - ZED_COMMIT_SHA = commitSha; + ZED_COMMIT_SHA = lib.optionalString (commitSha != null) "${commitSha}"; LK_CUSTOM_WEBRTC = pkgs.callPackage ./livekit-libwebrtc/package.nix { }; PROTOC = "${protobuf}/bin/protoc"; From 56b2eae745d3ae8317810fa129a633b3268be1d0 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:26:19 +0100 Subject: [PATCH 08/12] audio: Run webrtc receiver task on the realtime-priority thread as well (#51315) Co-authored-by: Jakub Konka Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed issues with tremendous audio latency in long-running collab calls. Co-authored-by: Jakub Konka --- crates/livekit_client/src/livekit_client/playback.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index f62de78b4f9fb702f03943b06270abb41aa68e34..88ebdfd389498ae00ad434eb22726a84a5fe1e01 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -111,7 +111,7 @@ impl AudioStack { source.num_channels as i32, ); - let receive_task = self.executor.spawn({ + let receive_task = self.executor.spawn_with_priority(Priority::RealtimeAudio, { let source = source.clone(); async move { while let Some(frame) = stream.next().await { @@ -202,7 +202,7 @@ impl AudioStack { let apm = self.apm.clone(); let (frame_tx, mut frame_rx) = futures::channel::mpsc::unbounded(); - let transmit_task = self.executor.spawn({ + let transmit_task = self.executor.spawn_with_priority(Priority::RealtimeAudio, { async move { while let Some(frame) = frame_rx.next().await { source.capture_frame(&frame).await.log_err(); From 9fb57b0daf1933e965b22543257b78bc4f22d376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:54:51 +0100 Subject: [PATCH 09/12] language_model: Centralize LlmApiToken to a singleton (#51225) The edit prediction, web search and completions endpoints in Cloud all use tokens called LlmApiToken. These were independently created, cached, and refreshed in three places: the cloud language model provider, the edit prediction store, and the cloud web search provider. Each held its own LlmApiToken instance, meaning three separate requests to get these tokens at startup / login and three redundant refreshes whenever the server signaled a token update was needed. We already had a global singleton reacting to the refresh signals: RefreshLlmTokenListener. It now holds a single LlmApiToken that all three services use, performs the refresh itself, and emits RefreshLlmTokenEvent only after the token is fresh. That event is used by the language model provider to re-fetch models after a refresh. The singleton is accessed only through `LlmApiToken::global()`. I have tested this manually, and it token acquisition and usage appear to be working fine. Edit: I've tested it with a long running session, and refresh seems to be working fine too. Release Notes: - N/A --------- Co-authored-by: Marshall Bowers --- crates/edit_prediction/src/edit_prediction.rs | 25 ++---------- .../src/edit_prediction_tests.rs | 1 + .../src/{model/mod.rs => model.rs} | 0 .../language_model/src/model/cloud_model.rs | 38 ++++++++++++++++--- crates/language_models/src/provider/cloud.rs | 6 +-- crates/web_search_providers/src/cloud.rs | 26 ++----------- 6 files changed, 43 insertions(+), 53 deletions(-) rename crates/language_model/src/{model/mod.rs => model.rs} (100%) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 5e1c9f9a03ec0c4bff0bbd60a9aefc6a06fa5368..63240ddd53108f0b2450386150958e23f975d7ed 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -23,14 +23,14 @@ use futures::{ use gpui::BackgroundExecutor; use gpui::http_client::Url; use gpui::{ - App, AsyncApp, Entity, EntityId, Global, SharedString, Subscription, Task, WeakEntity, actions, + App, AsyncApp, Entity, EntityId, Global, SharedString, Task, WeakEntity, actions, http_client::{self, AsyncBody, Method}, prelude::*, }; use language::language_settings::all_language_settings; use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToOffset, ToPoint}; use language::{BufferSnapshot, OffsetRangeExt}; -use language_model::{LlmApiToken, NeedsLlmTokenRefresh, RefreshLlmTokenListener}; +use language_model::{LlmApiToken, NeedsLlmTokenRefresh}; use project::{DisableAiSettings, Project, ProjectPath, WorktreeId}; use release_channel::AppVersion; use semver::Version; @@ -133,7 +133,6 @@ pub struct EditPredictionStore { client: Arc, user_store: Entity, llm_token: LlmApiToken, - _llm_token_subscription: Subscription, _fetch_experiments_task: Task<()>, projects: HashMap, update_required: bool, @@ -674,10 +673,9 @@ impl EditPredictionStore { } pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); let data_collection_choice = Self::load_data_collection_choice(); - let llm_token = LlmApiToken::default(); + let llm_token = LlmApiToken::global(cx); let (reject_tx, reject_rx) = mpsc::unbounded(); cx.background_spawn({ @@ -721,23 +719,6 @@ impl EditPredictionStore { user_store, llm_token, _fetch_experiments_task: fetch_experiments_task, - _llm_token_subscription: cx.subscribe( - &refresh_llm_token_listener, - |this, _listener, _event, cx| { - let client = this.client.clone(); - let llm_token = this.llm_token.clone(); - let organization_id = this - .user_store - .read(cx) - .current_organization() - .map(|organization| organization.id.clone()); - cx.spawn(async move |_this, _cx| { - llm_token.refresh(&client, organization_id).await?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - }, - ), update_required: false, edit_prediction_model: EditPredictionModel::Zeta, zeta2_raw_config: Self::zeta2_raw_config_from_env(), diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index ad237e6f8fb31708dbabc6e8332ce0c164877004..8f97df2c308980e1c2c89838609b30e1aedb1917 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -21,6 +21,7 @@ use language::{ Anchor, Buffer, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSeverity, Operation, Point, Selection, SelectionGoal, }; +use language_model::RefreshLlmTokenListener; use lsp::LanguageServerId; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_matches}; diff --git a/crates/language_model/src/model/mod.rs b/crates/language_model/src/model.rs similarity index 100% rename from crates/language_model/src/model/mod.rs rename to crates/language_model/src/model.rs diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index e64cc43edd8eef6cfaf0c6c966365c81d37b611c..e384ce05fa390677529235442c4cb91186520a02 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -30,6 +30,13 @@ impl fmt::Display for PaymentRequiredError { pub struct LlmApiToken(Arc>>); impl LlmApiToken { + pub fn global(cx: &App) -> Self { + RefreshLlmTokenListener::global(cx) + .read(cx) + .llm_api_token + .clone() + } + pub async fn acquire( &self, client: &Arc, @@ -102,13 +109,16 @@ struct GlobalRefreshLlmTokenListener(Entity); impl Global for GlobalRefreshLlmTokenListener {} -pub struct RefreshLlmTokenEvent; +pub struct LlmTokenRefreshedEvent; pub struct RefreshLlmTokenListener { + client: Arc, + user_store: Entity, + llm_api_token: LlmApiToken, _subscription: Subscription, } -impl EventEmitter for RefreshLlmTokenListener {} +impl EventEmitter for RefreshLlmTokenListener {} impl RefreshLlmTokenListener { pub fn register(client: Arc, user_store: Entity, cx: &mut App) { @@ -128,21 +138,39 @@ impl RefreshLlmTokenListener { } }); - let subscription = cx.subscribe(&user_store, |_this, _user_store, event, cx| { + let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| { if matches!(event, client::user::Event::OrganizationChanged) { - cx.emit(RefreshLlmTokenEvent); + this.refresh(cx); } }); Self { + client, + user_store, + llm_api_token: LlmApiToken::default(), _subscription: subscription, } } + fn refresh(&self, cx: &mut Context) { + let client = self.client.clone(); + let llm_api_token = self.llm_api_token.clone(); + let organization_id = self + .user_store + .read(cx) + .current_organization() + .map(|o| o.id.clone()); + cx.spawn(async move |this, cx| { + llm_api_token.refresh(&client, organization_id).await?; + this.update(cx, |_this, cx| cx.emit(LlmTokenRefreshedEvent)) + }) + .detach_and_log_err(cx); + } + fn handle_refresh_llm_token(this: Entity, message: &MessageToClient, cx: &mut App) { match message { MessageToClient::UserUpdated => { - this.update(cx, |_this, cx| cx.emit(RefreshLlmTokenEvent)); + this.update(cx, |this, cx| this.refresh(cx)); } } } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 4e705a8d62a5446b17bcc95a7dc75152b0c3269c..610b0167b86f8bf4426b671cedad45a28c3fdc6d 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -109,9 +109,10 @@ impl State { cx: &mut Context, ) -> Self { let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); + let llm_api_token = LlmApiToken::global(cx); Self { client: client.clone(), - llm_api_token: LlmApiToken::default(), + llm_api_token, user_store: user_store.clone(), status, models: Vec::new(), @@ -158,9 +159,6 @@ impl State { .current_organization() .map(|o| o.id.clone()); cx.spawn(async move |this, cx| { - llm_api_token - .refresh(&client, organization_id.clone()) - .await?; let response = Self::fetch_models(client, llm_api_token, organization_id).await?; this.update(cx, |this, cx| { diff --git a/crates/web_search_providers/src/cloud.rs b/crates/web_search_providers/src/cloud.rs index c8bc89953f2b2d3ec62bac07e80f2737522824f7..51be6c9ddff01a956eebabe3e44166ae15de4515 100644 --- a/crates/web_search_providers/src/cloud.rs +++ b/crates/web_search_providers/src/cloud.rs @@ -5,9 +5,9 @@ use client::{Client, UserStore}; use cloud_api_types::OrganizationId; use cloud_llm_client::{WebSearchBody, WebSearchResponse}; use futures::AsyncReadExt as _; -use gpui::{App, AppContext, Context, Entity, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, Task}; use http_client::{HttpClient, Method}; -use language_model::{LlmApiToken, NeedsLlmTokenRefresh, RefreshLlmTokenListener}; +use language_model::{LlmApiToken, NeedsLlmTokenRefresh}; use web_search::{WebSearchProvider, WebSearchProviderId}; pub struct CloudWebSearchProvider { @@ -26,34 +26,16 @@ pub struct State { client: Arc, user_store: Entity, llm_api_token: LlmApiToken, - _llm_token_subscription: Subscription, } impl State { pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); + let llm_api_token = LlmApiToken::global(cx); Self { client, user_store, - llm_api_token: LlmApiToken::default(), - _llm_token_subscription: cx.subscribe( - &refresh_llm_token_listener, - |this, _, _event, cx| { - let client = this.client.clone(); - let llm_api_token = this.llm_api_token.clone(); - let organization_id = this - .user_store - .read(cx) - .current_organization() - .map(|o| o.id.clone()); - cx.spawn(async move |_this, _cx| { - llm_api_token.refresh(&client, organization_id).await?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - }, - ), + llm_api_token, } } } From 9d2e2c859b9af0821f2c65a26845b49b571c2433 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:34:03 -0300 Subject: [PATCH 10/12] agent_ui: Add more UI refinements to the sidebar (#51325) Adjust the settings view and removes the new empty state from text threads. Release Notes: - N/A --- crates/agent_ui/src/agent_configuration.rs | 13 ++++++++-- crates/agent_ui/src/agent_panel.rs | 5 +++- crates/agent_ui/src/sidebar.rs | 2 +- crates/ui/src/components/chip.rs | 3 +-- crates/ui/src/components/label/label.rs | 28 ++++++++++++++++++++++ 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index aa316ba7c5efe5f679764cd7d4626a1f1310e4c6..46f92bfb2cfd60158bfb7c7aae9c16f3d9184695 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -228,6 +228,7 @@ impl AgentConfiguration { .unwrap_or(false); v_flex() + .min_w_0() .w_full() .when(is_expanded, |this| this.mb_2()) .child( @@ -312,6 +313,7 @@ impl AgentConfiguration { ) .child( v_flex() + .min_w_0() .w_full() .px_2() .gap_1() @@ -459,6 +461,7 @@ impl AgentConfiguration { }); v_flex() + .min_w_0() .w_full() .child(self.render_section_title( "LLM Providers", @@ -559,6 +562,7 @@ impl AgentConfiguration { }); v_flex() + .min_w_0() .border_b_1() .border_color(cx.theme().colors().border) .child(self.render_section_title( @@ -802,9 +806,12 @@ impl AgentConfiguration { }); v_flex() + .min_w_0() .id(item_id.clone()) .child( h_flex() + .min_w_0() + .w_full() .justify_between() .child( h_flex() @@ -820,13 +827,13 @@ impl AgentConfiguration { .tooltip(Tooltip::text(tooltip_text)) .child(status_indicator), ) - .child(Label::new(item_id).truncate()) + .child(Label::new(item_id).flex_shrink_0().truncate()) .child( div() .id("extension-source") + .min_w_0() .mt_0p5() .mx_1() - .flex_none() .tooltip(Tooltip::text(source_tooltip)) .child( Icon::new(source_icon) @@ -1019,6 +1026,7 @@ impl AgentConfiguration { }); v_flex() + .min_w_0() .border_b_1() .border_color(cx.theme().colors().border) .child( @@ -1217,6 +1225,7 @@ impl Render for AgentConfiguration { .id("assistant-configuration-content") .track_scroll(&self.scroll_handle) .size_full() + .min_w_0() .overflow_y_scroll() .child(self.render_agent_servers_section(cx)) .child(self.render_context_servers_section(window, cx)) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 50346bd752cec4432fb5a87e4df7cb4ce09aca83..1aefc99c020409a764ad2c44fe8477665f73c4bc 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -4131,7 +4131,10 @@ impl AgentPanel { ActiveView::History { .. } | ActiveView::Configuration ); - let use_v2_empty_toolbar = has_v2_flag && is_empty_state && !is_in_history_or_config; + let is_text_thread = matches!(&self.active_view, ActiveView::TextThread { .. }); + + let use_v2_empty_toolbar = + has_v2_flag && is_empty_state && !is_in_history_or_config && !is_text_thread; let is_sidebar_open = self .sidebar diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index e36cb750b4a74dc8d749501eed07941cd30c7b6f..595366dd0484254ed641e69713b519199547e8e3 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1250,7 +1250,7 @@ impl Sidebar { .focused(is_selected) .child( h_flex() - .p_1() + .py_1() .gap_1p5() .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) .child(Label::new(label).color(Color::Muted)) diff --git a/crates/ui/src/components/chip.rs b/crates/ui/src/components/chip.rs index ce709fe3962f742f5208808315f3bdac09c1f513..06dc7e6afa6fa8723985913dfece4205e360511e 100644 --- a/crates/ui/src/components/chip.rs +++ b/crates/ui/src/components/chip.rs @@ -81,8 +81,7 @@ impl RenderOnce for Chip { h_flex() .when_some(self.height, |this, h| this.h(h)) - .min_w_0() - .flex_initial() + .flex_none() .px_1() .border_1() .rounded_sm() diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index d0f50c00336eb971621e2da7bbaf53cf09569caa..405948ea06c7e86fcb3dec217186596bdaaf0aeb 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -73,6 +73,34 @@ impl Label { gpui::margin_style_methods!({ visibility: pub }); + + pub fn flex_1(mut self) -> Self { + self.style().flex_grow = Some(1.); + self.style().flex_shrink = Some(1.); + self.style().flex_basis = Some(gpui::relative(0.).into()); + self + } + + pub fn flex_none(mut self) -> Self { + self.style().flex_grow = Some(0.); + self.style().flex_shrink = Some(0.); + self + } + + pub fn flex_grow(mut self) -> Self { + self.style().flex_grow = Some(1.); + self + } + + pub fn flex_shrink(mut self) -> Self { + self.style().flex_shrink = Some(1.); + self + } + + pub fn flex_shrink_0(mut self) -> Self { + self.style().flex_shrink = Some(0.); + self + } } impl LabelCommon for Label { From 3dff4c57877c8a1b6bc2f6e2444b3b58ab9e637d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:15:43 -0300 Subject: [PATCH 11/12] agent_ui: Add timestamp to thread item in the sidebar (#51327) Release Notes: - N/A --- crates/agent_ui/src/sidebar.rs | 26 ++++ crates/ui/src/components/ai/thread_item.rs | 140 +++++++++++++++++---- 2 files changed, 143 insertions(+), 23 deletions(-) diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 595366dd0484254ed641e69713b519199547e8e3..3804e3f63678bcf771b27b2f05929a958531ab39 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1180,11 +1180,37 @@ impl Sidebar { let id = SharedString::from(format!("thread-entry-{}", ix)); + let timestamp = thread + .session_info + .created_at + .or(thread.session_info.updated_at) + .map(|entry_time| { + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + + let minutes = duration.num_minutes(); + let hours = duration.num_hours(); + let days = duration.num_days(); + let weeks = days / 7; + let months = days / 30; + + if minutes < 60 { + format!("{}m", minutes.max(1)) + } else if hours < 24 { + format!("{}h", hours) + } else if weeks < 4 { + format!("{}w", weeks.max(1)) + } else { + format!("{}mo", months.max(1)) + } + }); + ThreadItem::new(id, title) .icon(thread.icon) .when_some(thread.icon_from_external_svg.clone(), |this, svg| { this.custom_icon_from_external_svg(svg) }) + .when_some(timestamp, |this, ts| this.timestamp(ts)) .highlight_positions(thread.highlight_positions.to_vec()) .status(thread.status) .notified(has_notification) diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 1ab516b0cbbcb20c98bf61525779d2bd760ef260..5be91e9d98a1219dcfbbba70a5541ba7b827cfc5 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -245,6 +245,8 @@ impl RenderOnce for ThreadItem { let removed_count = self.removed.unwrap_or(0); let diff_stat_id = self.id.clone(); let has_worktree = self.worktree.is_some(); + let has_timestamp = !self.timestamp.is_empty(); + let timestamp = self.timestamp; v_flex() .id(self.id.clone()) @@ -253,13 +255,7 @@ impl RenderOnce for ThreadItem { .overflow_hidden() .cursor_pointer() .w_full() - .map(|this| { - if has_worktree || has_diff_stats { - this.p_2() - } else { - this.p_1() - } - }) + .p_1() .when(self.selected, |s| s.bg(color.element_active)) .border_1() .border_color(gpui::transparent_black()) @@ -310,23 +306,47 @@ impl RenderOnce for ThreadItem { .gap_1p5() .child(icon_container()) // Icon Spacing .child(worktree_label) - .child(dot_separator()) + .when(has_diff_stats || has_timestamp, |this| { + this.child(dot_separator()) + }) .when(has_diff_stats, |this| { this.child(DiffStat::new( diff_stat_id.clone(), added_count, removed_count, )) + }) + .when(has_diff_stats && has_timestamp, |this| { + this.child(dot_separator()) + }) + .when(has_timestamp, |this| { + this.child( + Label::new(timestamp.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) }), ) }) - .when(!has_worktree && has_diff_stats, |this| { + .when(!has_worktree && (has_diff_stats || has_timestamp), |this| { this.child( h_flex() .min_w_0() .gap_1p5() .child(icon_container()) // Icon Spacing - .child(DiffStat::new(diff_stat_id, added_count, removed_count)), + .when(has_diff_stats, |this| { + this.child(DiffStat::new(diff_stat_id, added_count, removed_count)) + }) + .when(has_diff_stats && has_timestamp, |this| { + this.child(dot_separator()) + }) + .when(has_timestamp, |this| { + this.child( + Label::new(timestamp.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), ) }) .when_some(self.on_click, |this, on_click| this.on_click(on_click)) @@ -349,21 +369,31 @@ impl Component for ThreadItem { let thread_item_examples = vec![ single_example( - "Default", + "Default (minutes)", container() .child( ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings") .icon(IconName::AiOpenAi) - .timestamp("1:33 AM"), + .timestamp("15m"), + ) + .into_any_element(), + ), + single_example( + "Timestamp Only (hours)", + container() + .child( + ThreadItem::new("ti-1b", "Thread with just a timestamp") + .icon(IconName::AiClaude) + .timestamp("3h"), ) .into_any_element(), ), single_example( - "Notified", + "Notified (weeks)", container() .child( ThreadItem::new("ti-2", "Refine thread view scrolling behavior") - .timestamp("12:12 AM") + .timestamp("1w") .notified(true), ) .into_any_element(), @@ -373,7 +403,7 @@ impl Component for ThreadItem { container() .child( ThreadItem::new("ti-2b", "Execute shell command in terminal") - .timestamp("12:15 AM") + .timestamp("2h") .status(AgentThreadStatus::WaitingForConfirmation), ) .into_any_element(), @@ -383,7 +413,7 @@ impl Component for ThreadItem { container() .child( ThreadItem::new("ti-2c", "Failed to connect to language server") - .timestamp("12:20 AM") + .timestamp("5h") .status(AgentThreadStatus::Error), ) .into_any_element(), @@ -394,7 +424,7 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock") .icon(IconName::AiClaude) - .timestamp("7:30 PM") + .timestamp("23h") .status(AgentThreadStatus::Running), ) .into_any_element(), @@ -405,30 +435,43 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock") .icon(IconName::AiClaude) - .timestamp("7:37 PM") + .timestamp("2w") .worktree("link-agent-panel"), ) .into_any_element(), ), single_example( - "With Changes", + "With Changes (months)", container() .child( ThreadItem::new("ti-5", "Managing user and project settings interactions") .icon(IconName::AiClaude) - .timestamp("7:37 PM") + .timestamp("1mo") .added(10) .removed(3), ) .into_any_element(), ), + single_example( + "Worktree + Changes + Timestamp", + container() + .child( + ThreadItem::new("ti-5b", "Full metadata example") + .icon(IconName::AiClaude) + .worktree("my-project") + .added(42) + .removed(17) + .timestamp("3w"), + ) + .into_any_element(), + ), single_example( "Selected Item", container() .child( ThreadItem::new("ti-6", "Refine textarea interaction behavior") .icon(IconName::AiGemini) - .timestamp("3:00 PM") + .timestamp("45m") .selected(true), ) .into_any_element(), @@ -439,23 +482,74 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-7", "Implement keyboard navigation") .icon(IconName::AiClaude) - .timestamp("4:00 PM") + .timestamp("12h") .focused(true), ) .into_any_element(), ), + single_example( + "Focused + Docked Right", + container() + .child( + ThreadItem::new("ti-7b", "Focused with right dock border") + .icon(IconName::AiClaude) + .timestamp("1w") + .focused(true) + .docked_right(true), + ) + .into_any_element(), + ), single_example( "Selected + Focused", container() .child( ThreadItem::new("ti-8", "Active and keyboard-focused thread") .icon(IconName::AiGemini) - .timestamp("5:00 PM") + .timestamp("2mo") .selected(true) .focused(true), ) .into_any_element(), ), + single_example( + "Hovered with Action Slot", + container() + .child( + ThreadItem::new("ti-9", "Hover to see action button") + .icon(IconName::AiClaude) + .timestamp("6h") + .hovered(true) + .action_slot( + IconButton::new("delete", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ), + ) + .into_any_element(), + ), + single_example( + "Search Highlight", + container() + .child( + ThreadItem::new("ti-10", "Implement keyboard navigation") + .icon(IconName::AiClaude) + .timestamp("4w") + .highlight_positions(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + ) + .into_any_element(), + ), + single_example( + "Worktree Search Highlight", + container() + .child( + ThreadItem::new("ti-11", "Search in worktree name") + .icon(IconName::AiClaude) + .timestamp("3mo") + .worktree("my-project-name") + .worktree_highlight_positions(vec![3, 4, 5, 6, 7, 8, 9, 10, 11]), + ) + .into_any_element(), + ), ]; Some( From becb24cd19405b6842cb4f0fb656a1a3853a0137 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 11 Mar 2026 17:28:29 -0400 Subject: [PATCH 12/12] cloud_api_types: Add `ZedBusiness` variant to `Plan` (#51329) This PR adds a `ZedBusiness` variant to the `Plan` enum. Closes CLO-480. Release Notes: - N/A --- crates/agent_ui/src/agent_configuration.rs | 1 + crates/ai_onboarding/src/ai_onboarding.rs | 19 +++++++++++++++++++ crates/ai_onboarding/src/ai_upsell_card.rs | 20 ++++++++++++++++++++ crates/ai_onboarding/src/plan_definitions.rs | 6 ++++++ crates/cloud_api_types/src/plan.rs | 1 + crates/title_bar/src/plan_chip.rs | 1 + 6 files changed, 48 insertions(+) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 46f92bfb2cfd60158bfb7c7aae9c16f3d9184695..ef3f3fdacc3d155554f3e2576ed1ed27c1d9ff0d 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -501,6 +501,7 @@ impl AgentConfiguration { Plan::ZedFree => ("Free", Color::Default, free_chip_bg), Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), + Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg), Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg), }; diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 0b1ccb4088e67de332c2bd2940ca5bdf77f1d3df..8b578d2e7f00a4f0dd139e074259d28e09932908 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -266,6 +266,20 @@ impl ZedAiOnboarding { .into_any_element() } + fn render_business_plan_state(&self, _cx: &mut App) -> AnyElement { + v_flex() + .gap_1() + .child(Headline::new("Welcome to Zed Business")) + .child( + Label::new("Here's what you get:") + .color(Color::Muted) + .mb_2(), + ) + .child(PlanDefinitions.business_plan()) + .children(self.render_dismiss_button()) + .into_any_element() + } + fn render_student_plan_state(&self, _cx: &mut App) -> AnyElement { v_flex() .gap_1() @@ -289,6 +303,7 @@ impl RenderOnce for ZedAiOnboarding { Some(Plan::ZedFree) => self.render_free_plan_state(cx), Some(Plan::ZedProTrial) => self.render_trial_state(cx), Some(Plan::ZedPro) => self.render_pro_plan_state(cx), + Some(Plan::ZedBusiness) => self.render_business_plan_state(cx), Some(Plan::ZedStudent) => self.render_student_plan_state(cx), } } else { @@ -353,6 +368,10 @@ impl Component for ZedAiOnboarding { "Pro Plan", onboarding(SignInStatus::SignedIn, Some(Plan::ZedPro), false), ), + single_example( + "Business Plan", + onboarding(SignInStatus::SignedIn, Some(Plan::ZedBusiness), false), + ), ]) .into_any_element(), ) diff --git a/crates/ai_onboarding/src/ai_upsell_card.rs b/crates/ai_onboarding/src/ai_upsell_card.rs index f1a1c4310def0b9b4dbabbc6a59eae940396fbb9..40a35f590d87a9928d4299199a99f223264e5ef3 100644 --- a/crates/ai_onboarding/src/ai_upsell_card.rs +++ b/crates/ai_onboarding/src/ai_upsell_card.rs @@ -250,6 +250,15 @@ impl RenderOnce for AiUpsellCard { .mb_2(), ) .child(PlanDefinitions.pro_plan()), + Some(Plan::ZedBusiness) => card + .child(certified_user_stamp) + .child(Label::new("You're in the Zed Business plan").size(LabelSize::Large)) + .child( + Label::new("Here's what you get:") + .color(Color::Muted) + .mb_2(), + ) + .child(PlanDefinitions.business_plan()), Some(Plan::ZedStudent) => card .child(certified_user_stamp) .child(Label::new("You're in the Zed Student plan").size(LabelSize::Large)) @@ -368,6 +377,17 @@ impl Component for AiUpsellCard { } .into_any_element(), ), + single_example( + "Business Plan", + AiUpsellCard { + sign_in_status: SignInStatus::SignedIn, + sign_in: Arc::new(|_, _| {}), + account_too_young: false, + user_plan: Some(Plan::ZedBusiness), + tab_index: Some(1), + } + .into_any_element(), + ), ], )) .into_any_element(), diff --git a/crates/ai_onboarding/src/plan_definitions.rs b/crates/ai_onboarding/src/plan_definitions.rs index 6d46a598c385b300fa579c69b0c58cfe51610c68..184815bcad9babb1892335c6207a79e1fe193c04 100644 --- a/crates/ai_onboarding/src/plan_definitions.rs +++ b/crates/ai_onboarding/src/plan_definitions.rs @@ -36,6 +36,12 @@ impl PlanDefinitions { .child(ListBulletItem::new("Usage-based billing beyond $5")) } + pub fn business_plan(&self) -> impl IntoElement { + List::new() + .child(ListBulletItem::new("Unlimited edit predictions")) + .child(ListBulletItem::new("Usage-based billing")) + } + pub fn student_plan(&self) -> impl IntoElement { List::new() .child(ListBulletItem::new("Unlimited edit predictions")) diff --git a/crates/cloud_api_types/src/plan.rs b/crates/cloud_api_types/src/plan.rs index e4a33e3c1933717f642848acc13dcf19b173e902..1f40d1ddb5f0e72871d5ecaee62b884132c158e4 100644 --- a/crates/cloud_api_types/src/plan.rs +++ b/crates/cloud_api_types/src/plan.rs @@ -9,6 +9,7 @@ pub enum Plan { ZedFree, ZedPro, ZedProTrial, + ZedBusiness, ZedStudent, } diff --git a/crates/title_bar/src/plan_chip.rs b/crates/title_bar/src/plan_chip.rs index edec0da2dea317bd122ece14d6afb90a31990c96..237e507ed8e4d1a5f63a7df116bf08fd69086bc2 100644 --- a/crates/title_bar/src/plan_chip.rs +++ b/crates/title_bar/src/plan_chip.rs @@ -33,6 +33,7 @@ impl RenderOnce for PlanChip { Plan::ZedFree => ("Free", Color::Default, free_chip_bg), Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), + Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg), Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg), };