Merge branch 'main' into flexible-agent-content

Max Brunsfeld and Eric Holk created

Co-authored-by: Eric Holk <eric@zed.dev>

Change summary

Cargo.toml                                           |   4 
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_configuration.rs           |  14 
crates/agent_ui/src/agent_panel.rs                   | 153 +
crates/agent_ui/src/agent_ui.rs                      |   4 
crates/agent_ui/src/connection_view.rs               |  16 
crates/agent_ui/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/sidebar.rs                       | 115 +
crates/agent_ui/src/thread_history.rs                | 880 -------------
crates/agent_ui/src/thread_history_view.rs           | 878 +++++++++++++
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/edit_prediction/src/edit_prediction.rs        |  25 
crates/edit_prediction/src/edit_prediction_tests.rs  |   1 
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 
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/icons/src/icons.rs                            |   6 
crates/image_viewer/src/image_viewer.rs              |  38 
crates/language_model/src/model.rs                   |   0 
crates/language_model/src/model/cloud_model.rs       |  38 
crates/language_models/src/provider/cloud.rs         |   6 
crates/languages/src/tsx/brackets.scm                |   9 
crates/livekit_client/src/livekit_client/playback.rs |   4 
crates/title_bar/src/plan_chip.rs                    |   1 
crates/ui/src/components/ai/thread_item.rs           | 203 ++
crates/ui/src/components/chip.rs                     |   3 
crates/ui/src/components/label/label.rs              |  28 
crates/ui/src/components/list/list_item.rs           |   8 
crates/web_search_providers/src/cloud.rs             |  26 
nix/build.nix                                        |   2 
49 files changed, 1,921 insertions(+), 1,144 deletions(-)

Detailed changes

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",

assets/icons/threads_sidebar_left_closed.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.1" width="5" height="12" rx="2" transform="matrix(-1 0 0 1 7 2)" fill="#C6CAD0"/>
+<path d="M7 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="1.5" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>

assets/icons/threads_sidebar_left_open.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.8" width="5" height="12" rx="2" transform="matrix(-1 0 0 1 7 2)" fill="#C6CAD0"/>
+<path d="M7 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="1.5" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>

assets/icons/threads_sidebar_right_closed.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.1" width="5" height="12" rx="2" transform="matrix(-1 0 0 1 14 2)" fill="#C6CAD0"/>
+<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="1.5" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>

assets/icons/threads_sidebar_right_open.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.8" width="5" height="12" rx="2" transform="matrix(-1 0 0 1 14 2)" fill="#C6CAD0"/>
+<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="1.5" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>

assets/icons/workspace_nav_closed.svg 🔗

@@ -1,5 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect opacity="0.2" width="7" height="12" rx="2" transform="matrix(-1 0 0 1 9 2)" fill="#C6CAD0"/>
-<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
-<rect x="2" y="2" width="12" height="12" rx="2" stroke="#C6CAD0" stroke-width="1.2"/>
-</svg>

assets/icons/workspace_nav_open.svg 🔗

@@ -1,5 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect width="7" height="12" rx="2" transform="matrix(-1 0 0 1 9 2)" fill="#C6CAD0"/>
-<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
-<rect x="2" y="2" width="12" height="12" rx="2" stroke="#C6CAD0" stroke-width="1.2"/>
-</svg>

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",
@@ -498,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),
             };
 
@@ -559,6 +563,7 @@ impl AgentConfiguration {
             });
 
         v_flex()
+            .min_w_0()
             .border_b_1()
             .border_color(cx.theme().colors().border)
             .child(self.render_section_title(
@@ -802,9 +807,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 +828,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 +1027,7 @@ impl AgentConfiguration {
             });
 
         v_flex()
+            .min_w_0()
             .border_b_1()
             .border_color(cx.theme().colors().border)
             .child(
@@ -1217,6 +1226,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))

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;
@@ -481,9 +481,17 @@ pub fn init(cx: &mut App) {
                     }
                     if let Some(panel) = workspace.panel::<AgentPanel>(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);
+                            }
                         }
                         // Explicitly notify the panel so the dock picks up
                         // the change to `has_main_element` via its observer.
@@ -867,6 +875,7 @@ pub struct AgentPanel {
     fs: Arc<dyn Fs>,
     language_registry: Arc<LanguageRegistry>,
     acp_history: Entity<ThreadHistory>,
+    acp_history_view: Entity<ThreadHistoryView>,
     text_thread_history: Entity<TextThreadHistory>,
     thread_store: Entity<ThreadStore>,
     text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
@@ -1077,14 +1086,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(),
@@ -1218,6 +1228,7 @@ impl AgentPanel {
             pending_serialization: None,
             onboarding,
             acp_history,
+            acp_history_view,
             text_thread_history,
             thread_store,
             selected_agent: AgentType::default(),
@@ -3058,7 +3069,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 {
@@ -3644,7 +3655,7 @@ impl AgentPanel {
             })
     }
 
-    fn render_sidebar_toggle(&self, cx: &Context<Self>) -> Option<AnyElement> {
+    fn render_sidebar_toggle(&self, docked_right: bool, cx: &Context<Self>) -> Option<AnyElement> {
         if !multi_workspace_enabled(cx) {
             return None;
         }
@@ -3655,20 +3666,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(),
         )
     }
@@ -4060,7 +4092,27 @@ 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
+            .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) =
@@ -4120,34 +4172,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,
@@ -4156,7 +4200,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()
@@ -4180,23 +4224,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 => {
@@ -4208,12 +4248,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,
@@ -4222,7 +4263,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()
@@ -4724,7 +4765,7 @@ impl 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 {

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!(

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)));
 

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(),
                                     )

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(

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| {

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| {

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<Self>,
     ) -> 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<Self>) {
@@ -1156,6 +1163,7 @@ impl Sidebar {
         ix: usize,
         thread: &ThreadEntry,
         is_selected: bool,
+        docked_right: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
         let has_notification = self
@@ -1171,11 +1179,38 @@ impl Sidebar {
         let workspace = thread.workspace.clone();
 
         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)
@@ -1187,6 +1222,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);
@@ -1240,7 +1276,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))
@@ -1301,6 +1337,7 @@ impl Sidebar {
         div()
             .w_full()
             .p_2()
+            .pt_1p5()
             .child(
                 Button::new(
                     SharedString::from(format!("new-thread-btn-{}", ix)),
@@ -1320,6 +1357,40 @@ impl Sidebar {
             )
             .into_any_element()
     }
+
+    fn render_sidebar_toggle_button(
+        &self,
+        docked_right: bool,
+        cx: &mut Context<Self>,
+    ) -> 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 +1487,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 +1509,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()

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<Rc<dyn AgentSessionList>>,
     sessions: Vec<AgentSessionInfo>,
-    scroll_handle: UniformListScrollHandle,
-    selected_index: usize,
-    hovered_index: Option<usize>,
-    search_editor: Entity<Editor>,
-    search_query: SharedString,
-    visible_items: Vec<ListItemType>,
-    local_timezone: UtcOffset,
-    confirming_delete_history: bool,
-    _visible_items_task: Task<()>,
     _refresh_task: Task<()>,
     _watch_task: Option<Task<()>>,
-    _subscriptions: Vec<gpui::Subscription>,
-}
-
-enum ListItemType {
-    BucketSeparator(TimeBucket),
-    Entry {
-        entry: AgentSessionInfo,
-        format: EntryTimeFormat,
-    },
-    SearchResult {
-        entry: AgentSessionInfo,
-        positions: Vec<usize>,
-    },
-}
-
-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<ThreadHistoryEvent> for ThreadHistory {}
-
 impl ThreadHistory {
-    pub fn new(
-        session_list: Option<Rc<dyn AgentSessionList>>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> 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<Rc<dyn AgentSessionList>>, cx: &mut Context<Self>) -> 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<Self>) {
-        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<Rc<dyn AgentSessionList>>,
@@ -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>) {
-        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<Self>,
-    ) {
+    fn refresh_sessions(&mut self, load_all_pages: bool, cx: &mut Context<Self>) {
         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<String> = 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<AgentSessionInfo>,
-        cx: &App,
-    ) -> Task<Vec<ListItemType>> {
-        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<AgentSessionInfo>,
-        cx: &App,
-    ) -> Task<Vec<ListItemType>> {
-        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<Self>) {
-        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<Self>,
-    ) {
-        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<Self>,
-    ) {
-        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<anyhow::Result<()>> {
+        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>,
-    ) {
-        self.set_selected_index(0, Bias::Right, cx);
-    }
-
-    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
-        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>) {
-        self.confirm_entry(self.selected_index, cx);
-    }
-
-    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
-        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>,
-    ) {
-        self.remove_thread(self.selected_index, cx)
-    }
-
-    fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
-        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<Self>) {
-        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>) {
-        self.confirming_delete_history = true;
-        cx.notify();
-    }
-
-    fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
-        self.confirming_delete_history = false;
-        cx.notify();
-    }
-
-    fn render_list_items(
-        &mut self,
-        range: Range<usize>,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Vec<AnyElement> {
-        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<Self>) -> 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<usize>,
-        cx: &Context<Self>,
-    ) -> 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::<IconButton>(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<Self>) -> 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<usize>, 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<ConnectionView>,
-    selected: bool,
-    hovered: bool,
-    supports_delete: bool,
-    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
-}
-
-impl HistoryEntryElement {
-    pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<ConnectionView>) -> 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::<IconButton>(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::<AgentPanel>(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<TimeBucket> 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));

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<ThreadHistory>,
+    scroll_handle: UniformListScrollHandle,
+    selected_index: usize,
+    hovered_index: Option<usize>,
+    search_editor: Entity<Editor>,
+    search_query: SharedString,
+    visible_items: Vec<ListItemType>,
+    local_timezone: UtcOffset,
+    confirming_delete_history: bool,
+    _visible_items_task: Task<()>,
+    _subscriptions: Vec<gpui::Subscription>,
+}
+
+enum ListItemType {
+    BucketSeparator(TimeBucket),
+    Entry {
+        entry: AgentSessionInfo,
+        format: EntryTimeFormat,
+    },
+    SearchResult {
+        entry: AgentSessionInfo,
+        positions: Vec<usize>,
+    },
+}
+
+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<ThreadHistoryViewEvent> for ThreadHistoryView {}
+
+impl ThreadHistoryView {
+    pub fn new(
+        history: Entity<ThreadHistory>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> 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<Self>) {
+        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<AgentSessionInfo>,
+        cx: &App,
+    ) -> Task<Vec<ListItemType>> {
+        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<AgentSessionInfo>,
+        cx: &App,
+    ) -> Task<Vec<ListItemType>> {
+        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<Self>) {
+        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<Self>,
+    ) {
+        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<Self>) {
+        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>,
+    ) {
+        self.set_selected_index(0, Bias::Right, cx);
+    }
+
+    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+        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>) {
+        self.confirm_entry(self.selected_index, cx);
+    }
+
+    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
+        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>,
+    ) {
+        self.remove_thread(self.selected_index, cx)
+    }
+
+    fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
+        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<Self>) {
+        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>) {
+        self.confirming_delete_history = true;
+        cx.notify();
+    }
+
+    fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        self.confirming_delete_history = false;
+        cx.notify();
+    }
+
+    fn render_list_items(
+        &mut self,
+        range: Range<usize>,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Vec<AnyElement> {
+        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<Self>) -> 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<usize>,
+        cx: &Context<Self>,
+    ) -> 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::<IconButton>(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<Self>) -> 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<usize>, 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<ConnectionView>,
+    selected: bool,
+    hovered: bool,
+    supports_delete: bool,
+    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
+}
+
+impl HistoryEntryElement {
+    pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<ConnectionView>) -> 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::<IconButton>(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::<AgentPanel>(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<TimeBucket> 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);
+    }
+}

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(),
         )

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(),

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"))

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<Client>,
     user_store: Entity<UserStore>,
     llm_token: LlmApiToken,
-    _llm_token_subscription: Subscription,
     _fetch_experiments_task: Task<()>,
     projects: HashMap<EntityId, ProjectState>,
     update_required: bool,
@@ -674,10 +673,9 @@ impl EditPredictionStore {
     }
 
     pub fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> 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(),

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};

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,

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);
         }
     }
 

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<gpui::Point<Pixels>>,
         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,

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<Anchor>,
+    mouse_position: Option<gpui::Point<Pixels>>,
     window: &mut Window,
     cx: &mut Context<Editor>,
 ) {
@@ -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<Editor>, 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<Editor>) -> 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<DiagnosticPopover>,
     pub triggered_from: Option<Anchor>,
     pub info_task: Option<Task<Option<()>>>,
+    pub closest_mouse_distance: Option<Pixels>,
+    pub hiding_delay_task: Option<Task<()>>,
 }
 
 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<Pixels>) -> 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<Pixels>,
+        bounds: Bounds<Pixels>,
+    ) -> 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<RefCell<bool>>,
     pub anchor: Option<Anchor>,
+    pub last_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
     _subscription: Option<Subscription>,
 }
 
@@ -898,13 +998,36 @@ impl InfoPopover {
         cx: &mut Context<Editor>,
     ) -> 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<RefCell<bool>>,
     pub anchor: Anchor,
+    pub last_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
     _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,

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<gpui::Point<Pixels>>,
         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);
         }
     }
 

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<dyn Fn(&ScrollWheelEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
 
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) type PinchListener =
+    Box<dyn Fn(&PinchEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
+
 pub(crate) type ClickListener = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
 
 pub(crate) type DragListener =
@@ -1644,6 +1725,8 @@ pub struct Interactivity {
     pub(crate) mouse_pressure_listeners: Vec<MousePressureListener>,
     pub(crate) mouse_move_listeners: Vec<MouseMoveListener>,
     pub(crate) scroll_wheel_listeners: Vec<ScrollWheelListener>,
+    #[cfg(any(target_os = "linux", target_os = "macos"))]
+    pub(crate) pinch_listeners: Vec<PinchListener>,
     pub(crate) key_down_listeners: Vec<KeyDownListener>,
     pub(crate) key_up_listeners: Vec<KeyUpListener>,
     pub(crate) modifiers_changed_listeners: Vec<ModifiersChangedListener>,
@@ -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()

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<Pixels>,
+
+    /// 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,
         }
     }

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 {

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<zwlr_layer_shell_v1::ZwlrLayerShellV1>,
     pub blur_manager: Option<org_kde_kwin_blur_manager::OrgKdeKwinBlurManager>,
     pub text_input_manager: Option<zwp_text_input_manager_v3::ZwpTextInputManagerV3>,
+    pub gesture_manager: Option<zwp_pointer_gestures_v1::ZwpPointerGesturesV1>,
     pub dialog: Option<xdg_wm_dialog_v1::XdgWmDialogV1>,
     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<CompositorGpuHint>,
     wl_seat: wl_seat::WlSeat, // TODO: Multi seat support
     wl_pointer: Option<wl_pointer::WlPointer>,
+    pinch_gesture: Option<zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1>,
+    pinch_scale: f32,
     wl_keyboard: Option<wl_keyboard::WlKeyboard>,
     cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
     data_device: Option<wl_data_device::WlDataDevice>,
@@ -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<wl_seat::WlSeat, ()> 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<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
     }
 }
 
+impl Dispatch<zwp_pointer_gestures_v1::ZwpPointerGesturesV1, ()> for WaylandClientStatePtr {
+    fn event(
+        _this: &mut Self,
+        _: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1,
+        _: <zwp_pointer_gestures_v1::ZwpPointerGesturesV1 as Proxy>::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        // The gesture manager doesn't generate events
+    }
+}
+
+impl Dispatch<zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1, ()>
+    for WaylandClientStatePtr
+{
+    fn event(
+        this: &mut Self,
+        _: &zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1,
+        event: <zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1 as Proxy>::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        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<wp_fractional_scale_v1::WpFractionalScaleV1, ObjectId> for WaylandClientStatePtr {
     fn event(
         this: &mut Self,

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 => {

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),

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,

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<Self>) {
+        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
+            })
     }
 }
 

crates/language_model/src/model/cloud_model.rs 🔗

@@ -30,6 +30,13 @@ impl fmt::Display for PaymentRequiredError {
 pub struct LlmApiToken(Arc<RwLock<Option<String>>>);
 
 impl LlmApiToken {
+    pub fn global(cx: &App) -> Self {
+        RefreshLlmTokenListener::global(cx)
+            .read(cx)
+            .llm_api_token
+            .clone()
+    }
+
     pub async fn acquire(
         &self,
         client: &Arc<Client>,
@@ -102,13 +109,16 @@ struct GlobalRefreshLlmTokenListener(Entity<RefreshLlmTokenListener>);
 
 impl Global for GlobalRefreshLlmTokenListener {}
 
-pub struct RefreshLlmTokenEvent;
+pub struct LlmTokenRefreshedEvent;
 
 pub struct RefreshLlmTokenListener {
+    client: Arc<Client>,
+    user_store: Entity<UserStore>,
+    llm_api_token: LlmApiToken,
     _subscription: Subscription,
 }
 
-impl EventEmitter<RefreshLlmTokenEvent> for RefreshLlmTokenListener {}
+impl EventEmitter<LlmTokenRefreshedEvent> for RefreshLlmTokenListener {}
 
 impl RefreshLlmTokenListener {
     pub fn register(client: Arc<Client>, user_store: Entity<UserStore>, 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<Self>) {
+        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<Self>, 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));
             }
         }
     }

crates/language_models/src/provider/cloud.rs 🔗

@@ -109,9 +109,10 @@ impl State {
         cx: &mut Context<Self>,
     ) -> 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| {

crates/languages/src/tsx/brackets.scm 🔗

@@ -7,14 +7,17 @@
 ("{" @open
   "}" @close)
 
-("<" @open
+(("<" @open
   ">" @close)
+  (#set! rainbow.exclude))
 
-("<" @open
+(("<" @open
   "/>" @close)
+  (#set! rainbow.exclude))
 
-("</" @open
+(("</" @open
   ">" @close)
+  (#set! rainbow.exclude))
 
 (("\"" @open
   "\"" @close)

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();

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),
         };
 

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<usize>,
     removed: Option<usize>,
     worktree: Option<SharedString>,
@@ -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<SharedString>) -> 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;
@@ -232,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())
@@ -240,17 +255,14 @@ impl RenderOnce for ThreadItem {
             .overflow_hidden()
             .cursor_pointer()
             .w_full()
-            .map(|this| {
-                if has_worktree || has_diff_stats {
-                    this.p_2()
-                } else {
-                    this.px_2().py_1()
-                }
-            })
+            .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 +282,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,22 +306,47 @@ impl RenderOnce for ThreadItem {
                         .gap_1p5()
                         .child(icon_container()) // Icon Spacing
                         .child(worktree_label)
+                        .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))
@@ -344,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(),
@@ -368,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(),
@@ -378,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(),
@@ -389,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(),
@@ -400,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(),
@@ -434,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(

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()

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 {

crates/ui/src/components/list/list_item.rs 🔗

@@ -48,6 +48,7 @@ pub struct ListItem {
     rounded: bool,
     overflow_x: bool,
     focused: Option<bool>,
+    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()

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<Client>,
     user_store: Entity<UserStore>,
     llm_api_token: LlmApiToken,
-    _llm_token_subscription: Subscription,
 }
 
 impl State {
     pub fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> 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,
         }
     }
 }

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";