diff --git a/Cargo.toml b/Cargo.toml
index f650dace84b1b2e6491acf2806077f72000605f5..36e7ca8cc7129af0ed7ab29dc5db338cdf33f7d4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -511,7 +511,6 @@ aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] }
aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] }
backtrace = "0.3"
base64 = "0.22"
-bincode = "1.2.1"
bitflags = "2.6.0"
brotli = "8.0.2"
bytes = "1.0"
@@ -570,7 +569,6 @@ human_bytes = "0.4.1"
html5ever = "0.27.0"
http = "1.1"
http-body = "1.0"
-hyper = "0.14"
ignore = "0.4.22"
image = "0.25.1"
imara-diff = "0.1.8"
@@ -688,7 +686,6 @@ serde_json_lenient = { version = "0.2", features = [
"raw_value",
] }
serde_path_to_error = "0.1.17"
-serde_repr = "0.1"
serde_urlencoded = "0.7"
sha2 = "0.10"
shellexpand = "2.1.0"
@@ -719,7 +716,6 @@ time = { version = "0.3", features = [
] }
tiny_http = "0.8"
tokio = { version = "1" }
-tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] }
tokio-socks = { version = "0.5.2", default-features = false, features = [
"futures-io",
"tokio",
diff --git a/assets/icons/threads_sidebar_left_closed.svg b/assets/icons/threads_sidebar_left_closed.svg
new file mode 100644
index 0000000000000000000000000000000000000000..feb1015254635ef65f90f2c9ea38efab74d01d60
--- /dev/null
+++ b/assets/icons/threads_sidebar_left_closed.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/threads_sidebar_left_open.svg b/assets/icons/threads_sidebar_left_open.svg
new file mode 100644
index 0000000000000000000000000000000000000000..8057b060a84d7d7ffcf29aff1c0c79a8764edc22
--- /dev/null
+++ b/assets/icons/threads_sidebar_left_open.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/threads_sidebar_right_closed.svg b/assets/icons/threads_sidebar_right_closed.svg
new file mode 100644
index 0000000000000000000000000000000000000000..10fa4b792fd65b5875dcf2cadab1fc12a123ab47
--- /dev/null
+++ b/assets/icons/threads_sidebar_right_closed.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/threads_sidebar_right_open.svg b/assets/icons/threads_sidebar_right_open.svg
new file mode 100644
index 0000000000000000000000000000000000000000..23a01eb3f82a5866157220172c868ed9ded46033
--- /dev/null
+++ b/assets/icons/threads_sidebar_right_open.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/workspace_nav_closed.svg b/assets/icons/workspace_nav_closed.svg
deleted file mode 100644
index ed1fce52d6826a4d10299f331358ff84e4caa973..0000000000000000000000000000000000000000
--- a/assets/icons/workspace_nav_closed.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/assets/icons/workspace_nav_open.svg b/assets/icons/workspace_nav_open.svg
deleted file mode 100644
index 464b6aac73c2aeaa9463a805aabc4559377bbfd3..0000000000000000000000000000000000000000
--- a/assets/icons/workspace_nav_open.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs
index aa316ba7c5efe5f679764cd7d4626a1f1310e4c6..ef3f3fdacc3d155554f3e2576ed1ed27c1d9ff0d 100644
--- a/crates/agent_ui/src/agent_configuration.rs
+++ b/crates/agent_ui/src/agent_configuration.rs
@@ -228,6 +228,7 @@ impl AgentConfiguration {
.unwrap_or(false);
v_flex()
+ .min_w_0()
.w_full()
.when(is_expanded, |this| this.mb_2())
.child(
@@ -312,6 +313,7 @@ impl AgentConfiguration {
)
.child(
v_flex()
+ .min_w_0()
.w_full()
.px_2()
.gap_1()
@@ -459,6 +461,7 @@ impl AgentConfiguration {
});
v_flex()
+ .min_w_0()
.w_full()
.child(self.render_section_title(
"LLM Providers",
@@ -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))
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 32b72cc983d8ab09129be1cc0acfa9d9f45c31ee..fae5afa48959b6bd992c5a6cee2f1e9449b5f8f3 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -48,7 +48,7 @@ use crate::{
NewNativeAgentThreadFromSummary,
};
use crate::{
- ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent,
+ ExpandMessageEditor, ThreadHistory, ThreadHistoryView, ThreadHistoryViewEvent,
text_thread_history::{TextThreadHistory, TextThreadHistoryEvent},
};
use agent_settings::AgentSettings;
@@ -481,9 +481,17 @@ pub fn init(cx: &mut App) {
}
if let Some(panel) = workspace.panel::(cx) {
if let Some(sidebar) = panel.read(cx).sidebar.clone() {
+ let was_open = sidebar.read(cx).is_open();
sidebar.update(cx, |sidebar, cx| {
sidebar.toggle(window, cx);
});
+ // When closing the sidebar, restore focus to the active pane
+ // to avoid "zombie focus" on the now-hidden sidebar elements
+ if was_open {
+ let active_pane = workspace.active_pane().clone();
+ let pane_focus = active_pane.read(cx).focus_handle(cx);
+ window.focus(&pane_focus, cx);
+ }
}
// 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,
language_registry: Arc,
acp_history: Entity,
+ acp_history_view: Entity,
text_thread_history: Entity,
thread_store: Entity,
text_thread_store: Entity,
@@ -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) -> Option {
+ fn render_sidebar_toggle(&self, docked_right: bool, cx: &Context) -> Option {
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 {
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 292db8fc7c0398fdd8c8800b8acc2b3c6df22740..52ce6f0bd7a312966b6602fb43be4074d7f3e620 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -33,6 +33,7 @@ pub mod test_support;
mod text_thread_editor;
mod text_thread_history;
mod thread_history;
+mod thread_history_view;
mod ui;
use std::rc::Rc;
@@ -74,7 +75,8 @@ pub(crate) use mode_selector::ModeSelector;
pub(crate) use model_selector::ModelSelector;
pub(crate) use model_selector_popover::ModelSelectorPopover;
pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor};
-pub(crate) use thread_history::*;
+pub(crate) use thread_history::ThreadHistory;
+pub(crate) use thread_history_view::*;
use zed_actions;
actions!(
diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs
index b896741cee26e14ed372480f80d6cf8302db180b..b562688a83b75b75a1b95c065b14d0484daef055 100644
--- a/crates/agent_ui/src/connection_view.rs
+++ b/crates/agent_ui/src/connection_view.rs
@@ -2901,7 +2901,7 @@ pub(crate) mod tests {
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
// Create history without an initial session list - it will be set after connection
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
let connection_store =
cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
@@ -3007,7 +3007,7 @@ pub(crate) mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
let connection_store =
cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
@@ -3066,7 +3066,7 @@ pub(crate) mod tests {
let captured_cwd = connection.captured_cwd.clone();
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
let connection_store =
cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
@@ -3123,7 +3123,7 @@ pub(crate) mod tests {
let captured_cwd = connection.captured_cwd.clone();
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
let connection_store =
cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
@@ -3180,7 +3180,7 @@ pub(crate) mod tests {
let captured_cwd = connection.captured_cwd.clone();
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
let connection_store =
cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
@@ -3498,7 +3498,7 @@ pub(crate) mod tests {
// Set up thread view in workspace 1
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
let connection_store =
cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx)));
@@ -3718,7 +3718,7 @@ pub(crate) mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
let connection_store =
cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
@@ -4454,7 +4454,7 @@ pub(crate) mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
let connection_store =
cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs
index d4d23f5a0a0722afc5c588a355a6a9de1b59d194..44f9e78a2bb47af6cb171194fbd5a34de7383f1b 100644
--- a/crates/agent_ui/src/connection_view/thread_view.rs
+++ b/crates/agent_ui/src/connection_view/thread_view.rs
@@ -7409,7 +7409,7 @@ impl ThreadView {
// TODO: Add keyboard navigation.
let is_hovered =
self.hovered_recent_history_item == Some(index);
- crate::thread_history::HistoryEntryElement::new(
+ crate::thread_history_view::HistoryEntryElement::new(
entry,
self.server_view.clone(),
)
diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs
index aef7f1f335eff7d092f924b9883ab0d64bbf65a8..17769335a1cc7e514bad15862d20d4048a089b7b 100644
--- a/crates/agent_ui/src/entry_view_state.rs
+++ b/crates/agent_ui/src/entry_view_state.rs
@@ -508,8 +508,7 @@ mod tests {
});
let thread_store = None;
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let view_state = cx.new(|_cx| {
EntryViewState::new(
diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs
index 4e7eecfe07aac84269cb1d325cc5a95943578863..2aee2b4601e126b25a977cf92d314970049026da 100644
--- a/crates/agent_ui/src/inline_assistant.rs
+++ b/crates/agent_ui/src/inline_assistant.rs
@@ -2155,7 +2155,7 @@ pub mod test {
});
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let history = cx.new(|cx| crate::ThreadHistory::new(None, window, cx));
+ let history = cx.new(|cx| crate::ThreadHistory::new(None, cx));
// Add editor to workspace
workspace.update(cx, |workspace, cx| {
diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs
index 6c2628f9d37efd0531d5663ac4b1d27d9ae5ae0f..c9067d4ec261261e66c7718b36ebcb96b2099fed 100644
--- a/crates/agent_ui/src/message_editor.rs
+++ b/crates/agent_ui/src/message_editor.rs
@@ -1708,8 +1708,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = None;
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -1822,8 +1821,7 @@ mod tests {
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let workspace_handle = workspace.downgrade();
let message_editor = workspace.update_in(cx, |_, window, cx| {
cx.new(|cx| {
@@ -1978,8 +1976,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = None;
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
let available_commands = Rc::new(RefCell::new(vec![
acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
@@ -2213,8 +2210,7 @@ mod tests {
}
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
@@ -2709,8 +2705,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -2810,8 +2805,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let session_id = acp::SessionId::new("thread-123");
let title = Some("Previous Conversation".into());
@@ -2886,8 +2880,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = None;
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -2943,8 +2936,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = None;
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -2998,8 +2990,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -3054,8 +3045,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -3119,8 +3109,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
@@ -3279,8 +3268,7 @@ mod tests {
});
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
// Create a new `MessageEditor`. The `EditorMode::full()` has to be used
// to ensure we have a fixed viewport, so we can eventually actually
@@ -3400,8 +3388,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
@@ -3483,8 +3470,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
@@ -3568,8 +3554,7 @@ mod tests {
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -3721,8 +3706,7 @@ mod tests {
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs
index ae3a4f0ccb9df6073ae24a9c482b6c56de0ea968..3804e3f63678bcf771b27b2f05929a958531ab39 100644
--- a/crates/agent_ui/src/sidebar.rs
+++ b/crates/agent_ui/src/sidebar.rs
@@ -713,6 +713,8 @@ impl Sidebar {
let is_group_header_after_first =
ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
+ let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
+
let rendered = match entry {
ListEntry::ProjectHeader {
path_list,
@@ -728,9 +730,12 @@ impl Sidebar {
highlight_positions,
*has_threads,
is_selected,
+ docked_right,
cx,
),
- ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx),
+ ListEntry::Thread(thread) => {
+ self.render_thread(ix, thread, is_selected, docked_right, cx)
+ }
ListEntry::ViewMore {
path_list,
remaining_count,
@@ -770,6 +775,7 @@ impl Sidebar {
highlight_positions: &[usize],
has_threads: bool,
is_selected: bool,
+ docked_right: bool,
cx: &mut Context,
) -> AnyElement {
let id = SharedString::from(format!("project-header-{}", ix));
@@ -815,12 +821,13 @@ impl Sidebar {
.group_name(group_name)
.toggle_state(is_active_workspace)
.focused(is_selected)
+ .docked_right(docked_right)
.child(
h_flex()
.relative()
.min_w_0()
.w_full()
- .p_1()
+ .py_1()
.gap_1p5()
.child(
Icon::new(disclosure_icon)
@@ -969,7 +976,7 @@ impl Sidebar {
}
fn has_filter_query(&self, cx: &App) -> bool {
- self.filter_editor.read(cx).buffer().read(cx).is_empty()
+ !self.filter_editor.read(cx).text(cx).is_empty()
}
fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) {
@@ -1156,6 +1163,7 @@ impl Sidebar {
ix: usize,
thread: &ThreadEntry,
is_selected: bool,
+ docked_right: bool,
cx: &mut Context,
) -> AnyElement {
let has_notification = self
@@ -1171,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,
+ ) -> 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()
diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs
index 01536b00e98d13a699457377a6ebf8e9e87a59b4..5e66d4468767e7002b8b5f6c79ffe8aaecf77127 100644
--- a/crates/agent_ui/src/thread_history.rs
+++ b/crates/agent_ui/src/thread_history.rs
@@ -1,118 +1,21 @@
-use crate::ConnectionView;
-use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate};
use agent_client_protocol as acp;
-use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
-use editor::{Editor, EditorEvent};
-use fuzzy::StringMatchCandidate;
-use gpui::{
- App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
- UniformListScrollHandle, WeakEntity, Window, uniform_list,
-};
-use std::{fmt::Display, ops::Range, rc::Rc};
-use text::Bias;
-use time::{OffsetDateTime, UtcOffset};
-use ui::{
- ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip,
- WithScrollbar, prelude::*,
-};
-
-const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
-
-fn thread_title(entry: &AgentSessionInfo) -> &SharedString {
- entry
- .title
- .as_ref()
- .filter(|title| !title.is_empty())
- .unwrap_or(DEFAULT_TITLE)
-}
+use gpui::{App, Task};
+use std::rc::Rc;
+use ui::prelude::*;
pub struct ThreadHistory {
session_list: Option>,
sessions: Vec,
- scroll_handle: UniformListScrollHandle,
- selected_index: usize,
- hovered_index: Option,
- search_editor: Entity,
- search_query: SharedString,
- visible_items: Vec,
- local_timezone: UtcOffset,
- confirming_delete_history: bool,
- _visible_items_task: Task<()>,
_refresh_task: Task<()>,
_watch_task: Option>,
- _subscriptions: Vec,
-}
-
-enum ListItemType {
- BucketSeparator(TimeBucket),
- Entry {
- entry: AgentSessionInfo,
- format: EntryTimeFormat,
- },
- SearchResult {
- entry: AgentSessionInfo,
- positions: Vec,
- },
-}
-
-impl ListItemType {
- fn history_entry(&self) -> Option<&AgentSessionInfo> {
- match self {
- ListItemType::Entry { entry, .. } => Some(entry),
- ListItemType::SearchResult { entry, .. } => Some(entry),
- _ => None,
- }
- }
}
-pub enum ThreadHistoryEvent {
- Open(AgentSessionInfo),
-}
-
-impl EventEmitter for ThreadHistory {}
-
impl ThreadHistory {
- pub fn new(
- session_list: Option>,
- window: &mut Window,
- cx: &mut Context,
- ) -> Self {
- let search_editor = cx.new(|cx| {
- let mut editor = Editor::single_line(window, cx);
- editor.set_placeholder_text("Search threads...", window, cx);
- editor
- });
-
- let search_editor_subscription =
- cx.subscribe(&search_editor, |this, search_editor, event, cx| {
- if let EditorEvent::BufferEdited = event {
- let query = search_editor.read(cx).text(cx);
- if this.search_query != query {
- this.search_query = query.into();
- this.update_visible_items(false, cx);
- }
- }
- });
-
- let scroll_handle = UniformListScrollHandle::default();
-
+ pub fn new(session_list: Option>, cx: &mut Context) -> Self {
let mut this = Self {
session_list: None,
sessions: Vec::new(),
- scroll_handle,
- selected_index: 0,
- hovered_index: None,
- visible_items: Default::default(),
- search_editor,
- local_timezone: UtcOffset::from_whole_seconds(
- chrono::Local::now().offset().local_minus_utc(),
- )
- .unwrap(),
- search_query: SharedString::default(),
- confirming_delete_history: false,
- _subscriptions: vec![search_editor_subscription],
- _visible_items_task: Task::ready(()),
_refresh_task: Task::ready(()),
_watch_task: None,
};
@@ -120,43 +23,6 @@ impl ThreadHistory {
this
}
- fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) {
- let entries = self.sessions.clone();
- let new_list_items = if self.search_query.is_empty() {
- self.add_list_separators(entries, cx)
- } else {
- self.filter_search_results(entries, cx)
- };
- let selected_history_entry = if preserve_selected_item {
- self.selected_history_entry().cloned()
- } else {
- None
- };
-
- self._visible_items_task = cx.spawn(async move |this, cx| {
- let new_visible_items = new_list_items.await;
- this.update(cx, |this, cx| {
- let new_selected_index = if let Some(history_entry) = selected_history_entry {
- new_visible_items
- .iter()
- .position(|visible_entry| {
- visible_entry
- .history_entry()
- .is_some_and(|entry| entry.session_id == history_entry.session_id)
- })
- .unwrap_or(0)
- } else {
- 0
- };
-
- this.visible_items = new_visible_items;
- this.set_selected_index(new_selected_index, Bias::Right, cx);
- cx.notify();
- })
- .ok();
- });
- }
-
pub fn set_session_list(
&mut self,
session_list: Option>,
@@ -170,9 +36,6 @@ impl ThreadHistory {
self.session_list = session_list;
self.sessions.clear();
- self.visible_items.clear();
- self.selected_index = 0;
- self._visible_items_task = Task::ready(());
self._refresh_task = Task::ready(());
let Some(session_list) = self.session_list.as_ref() else {
@@ -181,9 +44,8 @@ impl ThreadHistory {
return;
};
let Some(rx) = session_list.watch(cx) else {
- // No watch support - do a one-time refresh
self._watch_task = None;
- self.refresh_sessions(false, false, cx);
+ self.refresh_sessions(false, cx);
return;
};
session_list.notify_refresh();
@@ -191,7 +53,6 @@ impl ThreadHistory {
self._watch_task = Some(cx.spawn(async move |this, cx| {
while let Ok(first_update) = rx.recv().await {
let mut updates = vec![first_update];
- // Collect any additional updates that are already in the channel
while let Ok(update) = rx.try_recv() {
updates.push(update);
}
@@ -202,7 +63,7 @@ impl ThreadHistory {
.any(|u| matches!(u, SessionListUpdate::Refresh));
if needs_refresh {
- this.refresh_sessions(true, false, cx);
+ this.refresh_sessions(false, cx);
} else {
for update in updates {
if let SessionListUpdate::SessionInfo { session_id, update } = update {
@@ -217,7 +78,7 @@ impl ThreadHistory {
}
pub(crate) fn refresh_full_history(&mut self, cx: &mut Context) {
- self.refresh_sessions(true, true, cx);
+ self.refresh_sessions(true, cx);
}
fn apply_info_update(
@@ -258,23 +119,15 @@ impl ThreadHistory {
session.meta = Some(meta);
}
- self.update_visible_items(true, cx);
+ cx.notify();
}
- fn refresh_sessions(
- &mut self,
- preserve_selected_item: bool,
- load_all_pages: bool,
- cx: &mut Context,
- ) {
+ fn refresh_sessions(&mut self, load_all_pages: bool, cx: &mut Context) {
let Some(session_list) = self.session_list.clone() else {
- self.update_visible_items(preserve_selected_item, cx);
+ cx.notify();
return;
};
- // If a new refresh arrives while pagination is in progress, the previous
- // `_refresh_task` is cancelled. This is intentional (latest refresh wins),
- // but means sessions may be in a partial state until the new refresh completes.
self._refresh_task = cx.spawn(async move |this, cx| {
let mut cursor: Option = None;
let mut is_first_page = true;
@@ -305,7 +158,7 @@ impl ThreadHistory {
} else {
this.sessions.extend(page_sessions);
}
- this.update_visible_items(preserve_selected_item, cx);
+ cx.notify();
})
.ok();
@@ -378,693 +231,11 @@ impl ThreadHistory {
}
}
- fn add_list_separators(
- &self,
- entries: Vec,
- cx: &App,
- ) -> Task> {
- cx.background_spawn(async move {
- let mut items = Vec::with_capacity(entries.len() + 1);
- let mut bucket = None;
- let today = Local::now().naive_local().date();
-
- for entry in entries.into_iter() {
- let entry_bucket = entry
- .updated_at
- .map(|timestamp| {
- let entry_date = timestamp.with_timezone(&Local).naive_local().date();
- TimeBucket::from_dates(today, entry_date)
- })
- .unwrap_or(TimeBucket::All);
-
- if Some(entry_bucket) != bucket {
- bucket = Some(entry_bucket);
- items.push(ListItemType::BucketSeparator(entry_bucket));
- }
-
- items.push(ListItemType::Entry {
- entry,
- format: entry_bucket.into(),
- });
- }
- items
- })
- }
-
- fn filter_search_results(
- &self,
- entries: Vec,
- cx: &App,
- ) -> Task> {
- let query = self.search_query.clone();
- cx.background_spawn({
- let executor = cx.background_executor().clone();
- async move {
- let mut candidates = Vec::with_capacity(entries.len());
-
- for (idx, entry) in entries.iter().enumerate() {
- candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
- }
-
- const MAX_MATCHES: usize = 100;
-
- let matches = fuzzy::match_strings(
- &candidates,
- &query,
- false,
- true,
- MAX_MATCHES,
- &Default::default(),
- executor,
- )
- .await;
-
- matches
- .into_iter()
- .map(|search_match| ListItemType::SearchResult {
- entry: entries[search_match.candidate_id].clone(),
- positions: search_match.positions,
- })
- .collect()
- }
- })
- }
-
- fn search_produced_no_matches(&self) -> bool {
- self.visible_items.is_empty() && !self.search_query.is_empty()
- }
-
- fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
- self.get_history_entry(self.selected_index)
- }
-
- fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
- self.visible_items.get(visible_items_ix)?.history_entry()
- }
-
- fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) {
- if self.visible_items.len() == 0 {
- self.selected_index = 0;
- return;
- }
- while matches!(
- self.visible_items.get(index),
- None | Some(ListItemType::BucketSeparator(..))
- ) {
- index = match bias {
- Bias::Left => {
- if index == 0 {
- self.visible_items.len() - 1
- } else {
- index - 1
- }
- }
- Bias::Right => {
- if index >= self.visible_items.len() - 1 {
- 0
- } else {
- index + 1
- }
- }
- };
- }
- self.selected_index = index;
- self.scroll_handle
- .scroll_to_item(index, ScrollStrategy::Top);
- cx.notify()
- }
-
- pub fn select_previous(
- &mut self,
- _: &menu::SelectPrevious,
- _window: &mut Window,
- cx: &mut Context,
- ) {
- if self.selected_index == 0 {
- self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
- } else {
- self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
- }
- }
-
- pub fn select_next(
- &mut self,
- _: &menu::SelectNext,
- _window: &mut Window,
- cx: &mut Context,
- ) {
- if self.selected_index == self.visible_items.len() - 1 {
- self.set_selected_index(0, Bias::Right, cx);
+ pub(crate) fn delete_sessions(&self, cx: &mut App) -> Task> {
+ if let Some(session_list) = self.session_list.as_ref() {
+ session_list.delete_sessions(cx)
} else {
- self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
- }
- }
-
- fn select_first(
- &mut self,
- _: &menu::SelectFirst,
- _window: &mut Window,
- cx: &mut Context,
- ) {
- self.set_selected_index(0, Bias::Right, cx);
- }
-
- fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) {
- self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
- }
-
- fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) {
- self.confirm_entry(self.selected_index, cx);
- }
-
- fn confirm_entry(&mut self, ix: usize, cx: &mut Context) {
- let Some(entry) = self.get_history_entry(ix) else {
- return;
- };
- cx.emit(ThreadHistoryEvent::Open(entry.clone()));
- }
-
- fn remove_selected_thread(
- &mut self,
- _: &RemoveSelectedThread,
- _window: &mut Window,
- cx: &mut Context,
- ) {
- self.remove_thread(self.selected_index, cx)
- }
-
- fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) {
- let Some(entry) = self.get_history_entry(visible_item_ix) else {
- return;
- };
- let Some(session_list) = self.session_list.as_ref() else {
- return;
- };
- if !session_list.supports_delete() {
- return;
- }
- let task = session_list.delete_session(&entry.session_id, cx);
- task.detach_and_log_err(cx);
- }
-
- fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) {
- let Some(session_list) = self.session_list.as_ref() else {
- return;
- };
- if !session_list.supports_delete() {
- return;
- }
- session_list.delete_sessions(cx).detach_and_log_err(cx);
- self.confirming_delete_history = false;
- cx.notify();
- }
-
- fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) {
- self.confirming_delete_history = true;
- cx.notify();
- }
-
- fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) {
- self.confirming_delete_history = false;
- cx.notify();
- }
-
- fn render_list_items(
- &mut self,
- range: Range,
- _window: &mut Window,
- cx: &mut Context,
- ) -> Vec {
- self.visible_items
- .get(range.clone())
- .into_iter()
- .flatten()
- .enumerate()
- .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
- .collect()
- }
-
- fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement {
- match item {
- ListItemType::Entry { entry, format } => self
- .render_history_entry(entry, *format, ix, Vec::default(), cx)
- .into_any(),
- ListItemType::SearchResult { entry, positions } => self.render_history_entry(
- entry,
- EntryTimeFormat::DateAndTime,
- ix,
- positions.clone(),
- cx,
- ),
- ListItemType::BucketSeparator(bucket) => div()
- .px(DynamicSpacing::Base06.rems(cx))
- .pt_2()
- .pb_1()
- .child(
- Label::new(bucket.to_string())
- .size(LabelSize::XSmall)
- .color(Color::Muted),
- )
- .into_any_element(),
- }
- }
-
- fn render_history_entry(
- &self,
- entry: &AgentSessionInfo,
- format: EntryTimeFormat,
- ix: usize,
- highlight_positions: Vec,
- cx: &Context,
- ) -> AnyElement {
- let selected = ix == self.selected_index;
- let hovered = Some(ix) == self.hovered_index;
- let entry_time = entry.updated_at;
- let display_text = match (format, entry_time) {
- (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
- let now = Utc::now();
- let duration = now.signed_duration_since(entry_time);
- let days = duration.num_days();
-
- format!("{}d", days)
- }
- (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
- format.format_timestamp(entry_time.timestamp(), self.local_timezone)
- }
- (_, None) => "—".to_string(),
- };
-
- let title = thread_title(entry).clone();
- let full_date = entry_time
- .map(|time| {
- EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
- })
- .unwrap_or_else(|| "Unknown".to_string());
-
- h_flex()
- .w_full()
- .pb_1()
- .child(
- ListItem::new(ix)
- .rounded()
- .toggle_state(selected)
- .spacing(ListItemSpacing::Sparse)
- .start_slot(
- h_flex()
- .w_full()
- .gap_2()
- .justify_between()
- .child(
- HighlightedLabel::new(thread_title(entry), highlight_positions)
- .size(LabelSize::Small)
- .truncate(),
- )
- .child(
- Label::new(display_text)
- .color(Color::Muted)
- .size(LabelSize::XSmall),
- ),
- )
- .tooltip(move |_, cx| {
- Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
- })
- .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
- if *is_hovered {
- this.hovered_index = Some(ix);
- } else if this.hovered_index == Some(ix) {
- this.hovered_index = None;
- }
-
- cx.notify();
- }))
- .end_slot::(if hovered && self.supports_delete() {
- Some(
- IconButton::new("delete", IconName::Trash)
- .shape(IconButtonShape::Square)
- .icon_size(IconSize::XSmall)
- .icon_color(Color::Muted)
- .tooltip(move |_window, cx| {
- Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
- })
- .on_click(cx.listener(move |this, _, _, cx| {
- this.remove_thread(ix, cx);
- cx.stop_propagation()
- })),
- )
- } else {
- None
- })
- .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
- )
- .into_any_element()
- }
-}
-
-impl Focusable for ThreadHistory {
- fn focus_handle(&self, cx: &App) -> FocusHandle {
- self.search_editor.focus_handle(cx)
- }
-}
-
-impl Render for ThreadHistory {
- fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
- let has_no_history = self.is_empty();
-
- v_flex()
- .key_context("ThreadHistory")
- .size_full()
- .bg(cx.theme().colors().panel_background)
- .on_action(cx.listener(Self::select_previous))
- .on_action(cx.listener(Self::select_next))
- .on_action(cx.listener(Self::select_first))
- .on_action(cx.listener(Self::select_last))
- .on_action(cx.listener(Self::confirm))
- .on_action(cx.listener(Self::remove_selected_thread))
- .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
- this.remove_history(window, cx);
- }))
- .child(
- h_flex()
- .h(Tab::container_height(cx))
- .w_full()
- .py_1()
- .px_2()
- .gap_2()
- .justify_between()
- .border_b_1()
- .border_color(cx.theme().colors().border)
- .child(
- Icon::new(IconName::MagnifyingGlass)
- .color(Color::Muted)
- .size(IconSize::Small),
- )
- .child(self.search_editor.clone()),
- )
- .child({
- let view = v_flex()
- .id("list-container")
- .relative()
- .overflow_hidden()
- .flex_grow();
-
- if has_no_history {
- view.justify_center().items_center().child(
- Label::new("You don't have any past threads yet.")
- .size(LabelSize::Small)
- .color(Color::Muted),
- )
- } else if self.search_produced_no_matches() {
- view.justify_center()
- .items_center()
- .child(Label::new("No threads match your search.").size(LabelSize::Small))
- } else {
- view.child(
- uniform_list(
- "thread-history",
- self.visible_items.len(),
- cx.processor(|this, range: Range, window, cx| {
- this.render_list_items(range, window, cx)
- }),
- )
- .p_1()
- .pr_4()
- .track_scroll(&self.scroll_handle)
- .flex_grow(),
- )
- .vertical_scrollbar_for(&self.scroll_handle, window, cx)
- }
- })
- .when(!has_no_history && self.supports_delete(), |this| {
- this.child(
- h_flex()
- .p_2()
- .border_t_1()
- .border_color(cx.theme().colors().border_variant)
- .when(!self.confirming_delete_history, |this| {
- this.child(
- Button::new("delete_history", "Delete All History")
- .full_width()
- .style(ButtonStyle::Outlined)
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|this, _, window, cx| {
- this.prompt_delete_history(window, cx);
- })),
- )
- })
- .when(self.confirming_delete_history, |this| {
- this.w_full()
- .gap_2()
- .flex_wrap()
- .justify_between()
- .child(
- h_flex()
- .flex_wrap()
- .gap_1()
- .child(
- Label::new("Delete all threads?")
- .size(LabelSize::Small),
- )
- .child(
- Label::new("You won't be able to recover them later.")
- .size(LabelSize::Small)
- .color(Color::Muted),
- ),
- )
- .child(
- h_flex()
- .gap_1()
- .child(
- Button::new("cancel_delete", "Cancel")
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|this, _, window, cx| {
- this.cancel_delete_history(window, cx);
- })),
- )
- .child(
- Button::new("confirm_delete", "Delete")
- .style(ButtonStyle::Tinted(ui::TintColor::Error))
- .color(Color::Error)
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|_, _, window, cx| {
- window.dispatch_action(
- Box::new(RemoveHistory),
- cx,
- );
- })),
- ),
- )
- }),
- )
- })
- }
-}
-
-#[derive(IntoElement)]
-pub struct HistoryEntryElement {
- entry: AgentSessionInfo,
- thread_view: WeakEntity,
- selected: bool,
- hovered: bool,
- supports_delete: bool,
- on_hover: Box,
-}
-
-impl HistoryEntryElement {
- pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity) -> Self {
- Self {
- entry,
- thread_view,
- selected: false,
- hovered: false,
- supports_delete: false,
- on_hover: Box::new(|_, _, _| {}),
- }
- }
-
- pub fn supports_delete(mut self, supports_delete: bool) -> Self {
- self.supports_delete = supports_delete;
- self
- }
-
- pub fn hovered(mut self, hovered: bool) -> Self {
- self.hovered = hovered;
- self
- }
-
- pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
- self.on_hover = Box::new(on_hover);
- self
- }
-}
-
-impl RenderOnce for HistoryEntryElement {
- fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
- let id = ElementId::Name(self.entry.session_id.0.clone().into());
- let title = thread_title(&self.entry).clone();
- let formatted_time = self
- .entry
- .updated_at
- .map(|timestamp| {
- let now = chrono::Utc::now();
- let duration = now.signed_duration_since(timestamp);
-
- if duration.num_days() > 0 {
- format!("{}d", duration.num_days())
- } else if duration.num_hours() > 0 {
- format!("{}h ago", duration.num_hours())
- } else if duration.num_minutes() > 0 {
- format!("{}m ago", duration.num_minutes())
- } else {
- "Just now".to_string()
- }
- })
- .unwrap_or_else(|| "Unknown".to_string());
-
- ListItem::new(id)
- .rounded()
- .toggle_state(self.selected)
- .spacing(ListItemSpacing::Sparse)
- .start_slot(
- h_flex()
- .w_full()
- .gap_2()
- .justify_between()
- .child(Label::new(title).size(LabelSize::Small).truncate())
- .child(
- Label::new(formatted_time)
- .color(Color::Muted)
- .size(LabelSize::XSmall),
- ),
- )
- .on_hover(self.on_hover)
- .end_slot::(if (self.hovered || self.selected) && self.supports_delete {
- Some(
- IconButton::new("delete", IconName::Trash)
- .shape(IconButtonShape::Square)
- .icon_size(IconSize::XSmall)
- .icon_color(Color::Muted)
- .tooltip(move |_window, cx| {
- Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
- })
- .on_click({
- let thread_view = self.thread_view.clone();
- let session_id = self.entry.session_id.clone();
-
- move |_event, _window, cx| {
- if let Some(thread_view) = thread_view.upgrade() {
- thread_view.update(cx, |thread_view, cx| {
- thread_view.delete_history_entry(&session_id, cx);
- });
- }
- }
- }),
- )
- } else {
- None
- })
- .on_click({
- let thread_view = self.thread_view.clone();
- let entry = self.entry;
-
- move |_event, window, cx| {
- if let Some(workspace) = thread_view
- .upgrade()
- .and_then(|view| view.read(cx).workspace().upgrade())
- {
- if let Some(panel) = workspace.read(cx).panel::(cx) {
- panel.update(cx, |panel, cx| {
- panel.load_agent_thread(
- entry.session_id.clone(),
- entry.cwd.clone(),
- entry.title.clone(),
- window,
- cx,
- );
- });
- }
- }
- }
- })
- }
-}
-
-#[derive(Clone, Copy)]
-pub enum EntryTimeFormat {
- DateAndTime,
- TimeOnly,
-}
-
-impl EntryTimeFormat {
- fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
- let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
-
- match self {
- EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
- timestamp,
- OffsetDateTime::now_utc(),
- timezone,
- time_format::TimestampFormat::EnhancedAbsolute,
- ),
- EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
- }
- }
-}
-
-impl From for EntryTimeFormat {
- fn from(bucket: TimeBucket) -> Self {
- match bucket {
- TimeBucket::Today => EntryTimeFormat::TimeOnly,
- TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
- TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
- TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
- TimeBucket::All => EntryTimeFormat::DateAndTime,
- }
- }
-}
-
-#[derive(PartialEq, Eq, Clone, Copy, Debug)]
-enum TimeBucket {
- Today,
- Yesterday,
- ThisWeek,
- PastWeek,
- All,
-}
-
-impl TimeBucket {
- fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
- if date == reference {
- return TimeBucket::Today;
- }
-
- if date == reference - TimeDelta::days(1) {
- return TimeBucket::Yesterday;
- }
-
- let week = date.iso_week();
-
- if reference.iso_week() == week {
- return TimeBucket::ThisWeek;
- }
-
- let last_week = (reference - TimeDelta::days(7)).iso_week();
-
- if week == last_week {
- return TimeBucket::PastWeek;
- }
-
- TimeBucket::All
- }
-}
-
-impl Display for TimeBucket {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- TimeBucket::Today => write!(f, "Today"),
- TimeBucket::Yesterday => write!(f, "Yesterday"),
- TimeBucket::ThisWeek => write!(f, "This Week"),
- TimeBucket::PastWeek => write!(f, "Past Week"),
- TimeBucket::All => write!(f, "All"),
+ Task::ready(Ok(()))
}
}
}
@@ -1073,7 +244,6 @@ impl Display for TimeBucket {
mod tests {
use super::*;
use acp_thread::AgentSessionListResponse;
- use chrono::NaiveDate;
use gpui::TestAppContext;
use std::{
any::Any,
@@ -1246,9 +416,7 @@ mod tests {
vec![test_session("session-2", "Second")],
));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
history.update(cx, |history, _cx| {
@@ -1270,9 +438,7 @@ mod tests {
vec![test_session("session-2", "Second")],
));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
session_list.clear_requested_cursors();
@@ -1307,9 +473,7 @@ mod tests {
vec![test_session("session-2", "Second")],
));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
history.update(cx, |history, cx| history.refresh_full_history(cx));
@@ -1340,9 +504,7 @@ mod tests {
vec![test_session("session-2", "Second")],
));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
history.update(cx, |history, cx| history.refresh_full_history(cx));
@@ -1371,9 +533,7 @@ mod tests {
vec![test_session("session-2", "Second")],
));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
history.update(cx, |history, cx| history.refresh_full_history(cx));
@@ -1416,9 +576,7 @@ mod tests {
.with_async_responses(),
);
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
session_list.clear_requested_cursors();
@@ -1449,19 +607,15 @@ mod tests {
}];
let session_list = Rc::new(TestSessionList::new(sessions));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
- // Send a title update
session_list.send_update(SessionListUpdate::SessionInfo {
session_id: session_id.clone(),
update: acp::SessionInfoUpdate::new().title("New Title"),
});
cx.run_until_parked();
- // Check that the title was updated
history.update(cx, |history, _cx| {
let session = history.sessions.iter().find(|s| s.session_id == session_id);
assert_eq!(
@@ -1486,19 +640,15 @@ mod tests {
}];
let session_list = Rc::new(TestSessionList::new(sessions));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
- // Send an update that clears the title (null)
session_list.send_update(SessionListUpdate::SessionInfo {
session_id: session_id.clone(),
update: acp::SessionInfoUpdate::new().title(None::),
});
cx.run_until_parked();
- // Check that the title was cleared
history.update(cx, |history, _cx| {
let session = history.sessions.iter().find(|s| s.session_id == session_id);
assert_eq!(session.unwrap().title, None);
@@ -1520,19 +670,15 @@ mod tests {
}];
let session_list = Rc::new(TestSessionList::new(sessions));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
- // Send an update with no fields set (all undefined)
session_list.send_update(SessionListUpdate::SessionInfo {
session_id: session_id.clone(),
update: acp::SessionInfoUpdate::new(),
});
cx.run_until_parked();
- // Check that the title is unchanged
history.update(cx, |history, _cx| {
let session = history.sessions.iter().find(|s| s.session_id == session_id);
assert_eq!(
@@ -1557,12 +703,9 @@ mod tests {
}];
let session_list = Rc::new(TestSessionList::new(sessions));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
- // Send multiple updates before the executor runs
session_list.send_update(SessionListUpdate::SessionInfo {
session_id: session_id.clone(),
update: acp::SessionInfoUpdate::new().title("First Title"),
@@ -1573,7 +716,6 @@ mod tests {
});
cx.run_until_parked();
- // Check that the final title is "Second Title" (both applied in order)
history.update(cx, |history, _cx| {
let session = history.sessions.iter().find(|s| s.session_id == session_id);
assert_eq!(
@@ -1598,12 +740,9 @@ mod tests {
}];
let session_list = Rc::new(TestSessionList::new(sessions));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
- // Send an info update followed by a refresh
session_list.send_update(SessionListUpdate::SessionInfo {
session_id: session_id.clone(),
update: acp::SessionInfoUpdate::new().title("Local Update"),
@@ -1611,7 +750,6 @@ mod tests {
session_list.send_update(SessionListUpdate::Refresh);
cx.run_until_parked();
- // The refresh should have fetched from server, getting "Server Title"
history.update(cx, |history, _cx| {
let session = history.sessions.iter().find(|s| s.session_id == session_id);
assert_eq!(
@@ -1636,19 +774,15 @@ mod tests {
}];
let session_list = Rc::new(TestSessionList::new(sessions));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
- // Send an update for an unknown session
session_list.send_update(SessionListUpdate::SessionInfo {
session_id: acp::SessionId::new("unknown-session"),
update: acp::SessionInfoUpdate::new().title("Should Be Ignored"),
});
cx.run_until_parked();
- // Check that the known session is unchanged and no crash occurred
history.update(cx, |history, _cx| {
assert_eq!(history.sessions.len(), 1);
assert_eq!(
@@ -1657,43 +791,4 @@ mod tests {
);
});
}
-
- #[test]
- fn test_time_bucket_from_dates() {
- let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
-
- let date = today;
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
-
- let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
-
- let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
-
- let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
-
- let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
-
- let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
-
- // All: not in this week or last week
- let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
-
- // Test year boundary cases
- let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
-
- let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
- assert_eq!(
- TimeBucket::from_dates(new_year, date),
- TimeBucket::Yesterday
- );
-
- let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
- assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
- }
}
diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs
new file mode 100644
index 0000000000000000000000000000000000000000..1756fc46ed48e86dc4bf9c78f2c2ef79618ed43b
--- /dev/null
+++ b/crates/agent_ui/src/thread_history_view.rs
@@ -0,0 +1,878 @@
+use crate::thread_history::ThreadHistory;
+use crate::{AgentPanel, ConnectionView, RemoveHistory, RemoveSelectedThread};
+use acp_thread::AgentSessionInfo;
+use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
+use editor::{Editor, EditorEvent};
+use fuzzy::StringMatchCandidate;
+use gpui::{
+ AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
+ UniformListScrollHandle, WeakEntity, Window, uniform_list,
+};
+use std::{fmt::Display, ops::Range};
+use text::Bias;
+use time::{OffsetDateTime, UtcOffset};
+use ui::{
+ ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip,
+ WithScrollbar, prelude::*,
+};
+
+const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
+
+pub(crate) fn thread_title(entry: &AgentSessionInfo) -> &SharedString {
+ entry
+ .title
+ .as_ref()
+ .filter(|title| !title.is_empty())
+ .unwrap_or(DEFAULT_TITLE)
+}
+
+pub struct ThreadHistoryView {
+ history: Entity,
+ scroll_handle: UniformListScrollHandle,
+ selected_index: usize,
+ hovered_index: Option,
+ search_editor: Entity,
+ search_query: SharedString,
+ visible_items: Vec,
+ local_timezone: UtcOffset,
+ confirming_delete_history: bool,
+ _visible_items_task: Task<()>,
+ _subscriptions: Vec,
+}
+
+enum ListItemType {
+ BucketSeparator(TimeBucket),
+ Entry {
+ entry: AgentSessionInfo,
+ format: EntryTimeFormat,
+ },
+ SearchResult {
+ entry: AgentSessionInfo,
+ positions: Vec,
+ },
+}
+
+impl ListItemType {
+ fn history_entry(&self) -> Option<&AgentSessionInfo> {
+ match self {
+ ListItemType::Entry { entry, .. } => Some(entry),
+ ListItemType::SearchResult { entry, .. } => Some(entry),
+ _ => None,
+ }
+ }
+}
+
+pub enum ThreadHistoryViewEvent {
+ Open(AgentSessionInfo),
+}
+
+impl EventEmitter for ThreadHistoryView {}
+
+impl ThreadHistoryView {
+ pub fn new(
+ history: Entity,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
+ let search_editor = cx.new(|cx| {
+ let mut editor = Editor::single_line(window, cx);
+ editor.set_placeholder_text("Search threads...", window, cx);
+ editor
+ });
+
+ let search_editor_subscription =
+ cx.subscribe(&search_editor, |this, search_editor, event, cx| {
+ if let EditorEvent::BufferEdited = event {
+ let query = search_editor.read(cx).text(cx);
+ if this.search_query != query {
+ this.search_query = query.into();
+ this.update_visible_items(false, cx);
+ }
+ }
+ });
+
+ let history_subscription = cx.observe(&history, |this, _, cx| {
+ this.update_visible_items(true, cx);
+ });
+
+ let scroll_handle = UniformListScrollHandle::default();
+
+ let mut this = Self {
+ history,
+ scroll_handle,
+ selected_index: 0,
+ hovered_index: None,
+ visible_items: Default::default(),
+ search_editor,
+ local_timezone: UtcOffset::from_whole_seconds(
+ chrono::Local::now().offset().local_minus_utc(),
+ )
+ .unwrap(),
+ search_query: SharedString::default(),
+ confirming_delete_history: false,
+ _subscriptions: vec![search_editor_subscription, history_subscription],
+ _visible_items_task: Task::ready(()),
+ };
+ this.update_visible_items(false, cx);
+ this
+ }
+
+ fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) {
+ let entries = self.history.read(cx).sessions().to_vec();
+ let new_list_items = if self.search_query.is_empty() {
+ self.add_list_separators(entries, cx)
+ } else {
+ self.filter_search_results(entries, cx)
+ };
+ let selected_history_entry = if preserve_selected_item {
+ self.selected_history_entry().cloned()
+ } else {
+ None
+ };
+
+ self._visible_items_task = cx.spawn(async move |this, cx| {
+ let new_visible_items = new_list_items.await;
+ this.update(cx, |this, cx| {
+ let new_selected_index = if let Some(history_entry) = selected_history_entry {
+ new_visible_items
+ .iter()
+ .position(|visible_entry| {
+ visible_entry
+ .history_entry()
+ .is_some_and(|entry| entry.session_id == history_entry.session_id)
+ })
+ .unwrap_or(0)
+ } else {
+ 0
+ };
+
+ this.visible_items = new_visible_items;
+ this.set_selected_index(new_selected_index, Bias::Right, cx);
+ cx.notify();
+ })
+ .ok();
+ });
+ }
+
+ fn add_list_separators(
+ &self,
+ entries: Vec,
+ cx: &App,
+ ) -> Task> {
+ cx.background_spawn(async move {
+ let mut items = Vec::with_capacity(entries.len() + 1);
+ let mut bucket = None;
+ let today = Local::now().naive_local().date();
+
+ for entry in entries.into_iter() {
+ let entry_bucket = entry
+ .updated_at
+ .map(|timestamp| {
+ let entry_date = timestamp.with_timezone(&Local).naive_local().date();
+ TimeBucket::from_dates(today, entry_date)
+ })
+ .unwrap_or(TimeBucket::All);
+
+ if Some(entry_bucket) != bucket {
+ bucket = Some(entry_bucket);
+ items.push(ListItemType::BucketSeparator(entry_bucket));
+ }
+
+ items.push(ListItemType::Entry {
+ entry,
+ format: entry_bucket.into(),
+ });
+ }
+ items
+ })
+ }
+
+ fn filter_search_results(
+ &self,
+ entries: Vec,
+ cx: &App,
+ ) -> Task> {
+ let query = self.search_query.clone();
+ cx.background_spawn({
+ let executor = cx.background_executor().clone();
+ async move {
+ let mut candidates = Vec::with_capacity(entries.len());
+
+ for (idx, entry) in entries.iter().enumerate() {
+ candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
+ }
+
+ const MAX_MATCHES: usize = 100;
+
+ let matches = fuzzy::match_strings(
+ &candidates,
+ &query,
+ false,
+ true,
+ MAX_MATCHES,
+ &Default::default(),
+ executor,
+ )
+ .await;
+
+ matches
+ .into_iter()
+ .map(|search_match| ListItemType::SearchResult {
+ entry: entries[search_match.candidate_id].clone(),
+ positions: search_match.positions,
+ })
+ .collect()
+ }
+ })
+ }
+
+ fn search_produced_no_matches(&self) -> bool {
+ self.visible_items.is_empty() && !self.search_query.is_empty()
+ }
+
+ fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
+ self.get_history_entry(self.selected_index)
+ }
+
+ fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
+ self.visible_items.get(visible_items_ix)?.history_entry()
+ }
+
+ fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) {
+ if self.visible_items.len() == 0 {
+ self.selected_index = 0;
+ return;
+ }
+ while matches!(
+ self.visible_items.get(index),
+ None | Some(ListItemType::BucketSeparator(..))
+ ) {
+ index = match bias {
+ Bias::Left => {
+ if index == 0 {
+ self.visible_items.len() - 1
+ } else {
+ index - 1
+ }
+ }
+ Bias::Right => {
+ if index >= self.visible_items.len() - 1 {
+ 0
+ } else {
+ index + 1
+ }
+ }
+ };
+ }
+ self.selected_index = index;
+ self.scroll_handle
+ .scroll_to_item(index, ScrollStrategy::Top);
+ cx.notify()
+ }
+
+ fn select_previous(
+ &mut self,
+ _: &menu::SelectPrevious,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ if self.selected_index == 0 {
+ self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
+ } else {
+ self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
+ }
+ }
+
+ fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) {
+ if self.selected_index == self.visible_items.len() - 1 {
+ self.set_selected_index(0, Bias::Right, cx);
+ } else {
+ self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
+ }
+ }
+
+ fn select_first(
+ &mut self,
+ _: &menu::SelectFirst,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.set_selected_index(0, Bias::Right, cx);
+ }
+
+ fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) {
+ self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
+ }
+
+ fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) {
+ self.confirm_entry(self.selected_index, cx);
+ }
+
+ fn confirm_entry(&mut self, ix: usize, cx: &mut Context) {
+ let Some(entry) = self.get_history_entry(ix) else {
+ return;
+ };
+ cx.emit(ThreadHistoryViewEvent::Open(entry.clone()));
+ }
+
+ fn remove_selected_thread(
+ &mut self,
+ _: &RemoveSelectedThread,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.remove_thread(self.selected_index, cx)
+ }
+
+ fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) {
+ let Some(entry) = self.get_history_entry(visible_item_ix) else {
+ return;
+ };
+ if !self.history.read(cx).supports_delete() {
+ return;
+ }
+ let session_id = entry.session_id.clone();
+ self.history.update(cx, |history, cx| {
+ history
+ .delete_session(&session_id, cx)
+ .detach_and_log_err(cx);
+ });
+ }
+
+ fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) {
+ if !self.history.read(cx).supports_delete() {
+ return;
+ }
+ self.history.update(cx, |history, cx| {
+ history.delete_sessions(cx).detach_and_log_err(cx);
+ });
+ self.confirming_delete_history = false;
+ cx.notify();
+ }
+
+ fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) {
+ self.confirming_delete_history = true;
+ cx.notify();
+ }
+
+ fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) {
+ self.confirming_delete_history = false;
+ cx.notify();
+ }
+
+ fn render_list_items(
+ &mut self,
+ range: Range,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) -> Vec {
+ self.visible_items
+ .get(range.clone())
+ .into_iter()
+ .flatten()
+ .enumerate()
+ .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
+ .collect()
+ }
+
+ fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement {
+ match item {
+ ListItemType::Entry { entry, format } => self
+ .render_history_entry(entry, *format, ix, Vec::default(), cx)
+ .into_any(),
+ ListItemType::SearchResult { entry, positions } => self.render_history_entry(
+ entry,
+ EntryTimeFormat::DateAndTime,
+ ix,
+ positions.clone(),
+ cx,
+ ),
+ ListItemType::BucketSeparator(bucket) => div()
+ .px(DynamicSpacing::Base06.rems(cx))
+ .pt_2()
+ .pb_1()
+ .child(
+ Label::new(bucket.to_string())
+ .size(LabelSize::XSmall)
+ .color(Color::Muted),
+ )
+ .into_any_element(),
+ }
+ }
+
+ fn render_history_entry(
+ &self,
+ entry: &AgentSessionInfo,
+ format: EntryTimeFormat,
+ ix: usize,
+ highlight_positions: Vec,
+ cx: &Context,
+ ) -> AnyElement {
+ let selected = ix == self.selected_index;
+ let hovered = Some(ix) == self.hovered_index;
+ let entry_time = entry.updated_at;
+ let display_text = match (format, entry_time) {
+ (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
+ let now = Utc::now();
+ let duration = now.signed_duration_since(entry_time);
+ let days = duration.num_days();
+
+ format!("{}d", days)
+ }
+ (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
+ format.format_timestamp(entry_time.timestamp(), self.local_timezone)
+ }
+ (_, None) => "—".to_string(),
+ };
+
+ let title = thread_title(entry).clone();
+ let full_date = entry_time
+ .map(|time| {
+ EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
+ })
+ .unwrap_or_else(|| "Unknown".to_string());
+
+ let supports_delete = self.history.read(cx).supports_delete();
+
+ h_flex()
+ .w_full()
+ .pb_1()
+ .child(
+ ListItem::new(ix)
+ .rounded()
+ .toggle_state(selected)
+ .spacing(ListItemSpacing::Sparse)
+ .start_slot(
+ h_flex()
+ .w_full()
+ .gap_2()
+ .justify_between()
+ .child(
+ HighlightedLabel::new(thread_title(entry), highlight_positions)
+ .size(LabelSize::Small)
+ .truncate(),
+ )
+ .child(
+ Label::new(display_text)
+ .color(Color::Muted)
+ .size(LabelSize::XSmall),
+ ),
+ )
+ .tooltip(move |_, cx| {
+ Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
+ })
+ .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
+ if *is_hovered {
+ this.hovered_index = Some(ix);
+ } else if this.hovered_index == Some(ix) {
+ this.hovered_index = None;
+ }
+
+ cx.notify();
+ }))
+ .end_slot::(if hovered && supports_delete {
+ Some(
+ IconButton::new("delete", IconName::Trash)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::XSmall)
+ .icon_color(Color::Muted)
+ .tooltip(move |_window, cx| {
+ Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
+ })
+ .on_click(cx.listener(move |this, _, _, cx| {
+ this.remove_thread(ix, cx);
+ cx.stop_propagation()
+ })),
+ )
+ } else {
+ None
+ })
+ .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
+ )
+ .into_any_element()
+ }
+}
+
+impl Focusable for ThreadHistoryView {
+ fn focus_handle(&self, cx: &App) -> FocusHandle {
+ self.search_editor.focus_handle(cx)
+ }
+}
+
+impl Render for ThreadHistoryView {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let has_no_history = self.history.read(cx).is_empty();
+ let supports_delete = self.history.read(cx).supports_delete();
+
+ v_flex()
+ .key_context("ThreadHistory")
+ .size_full()
+ .bg(cx.theme().colors().panel_background)
+ .on_action(cx.listener(Self::select_previous))
+ .on_action(cx.listener(Self::select_next))
+ .on_action(cx.listener(Self::select_first))
+ .on_action(cx.listener(Self::select_last))
+ .on_action(cx.listener(Self::confirm))
+ .on_action(cx.listener(Self::remove_selected_thread))
+ .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
+ this.remove_history(window, cx);
+ }))
+ .child(
+ h_flex()
+ .h(Tab::container_height(cx))
+ .w_full()
+ .py_1()
+ .px_2()
+ .gap_2()
+ .justify_between()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ Icon::new(IconName::MagnifyingGlass)
+ .color(Color::Muted)
+ .size(IconSize::Small),
+ )
+ .child(self.search_editor.clone()),
+ )
+ .child({
+ let view = v_flex()
+ .id("list-container")
+ .relative()
+ .overflow_hidden()
+ .flex_grow();
+
+ if has_no_history {
+ view.justify_center().items_center().child(
+ Label::new("You don't have any past threads yet.")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ } else if self.search_produced_no_matches() {
+ view.justify_center()
+ .items_center()
+ .child(Label::new("No threads match your search.").size(LabelSize::Small))
+ } else {
+ view.child(
+ uniform_list(
+ "thread-history",
+ self.visible_items.len(),
+ cx.processor(|this, range: Range, window, cx| {
+ this.render_list_items(range, window, cx)
+ }),
+ )
+ .p_1()
+ .pr_4()
+ .track_scroll(&self.scroll_handle)
+ .flex_grow(),
+ )
+ .vertical_scrollbar_for(&self.scroll_handle, window, cx)
+ }
+ })
+ .when(!has_no_history && supports_delete, |this| {
+ this.child(
+ h_flex()
+ .p_2()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
+ .when(!self.confirming_delete_history, |this| {
+ this.child(
+ Button::new("delete_history", "Delete All History")
+ .full_width()
+ .style(ButtonStyle::Outlined)
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.prompt_delete_history(window, cx);
+ })),
+ )
+ })
+ .when(self.confirming_delete_history, |this| {
+ this.w_full()
+ .gap_2()
+ .flex_wrap()
+ .justify_between()
+ .child(
+ h_flex()
+ .flex_wrap()
+ .gap_1()
+ .child(
+ Label::new("Delete all threads?")
+ .size(LabelSize::Small),
+ )
+ .child(
+ Label::new("You won't be able to recover them later.")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .child(
+ h_flex()
+ .gap_1()
+ .child(
+ Button::new("cancel_delete", "Cancel")
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.cancel_delete_history(window, cx);
+ })),
+ )
+ .child(
+ Button::new("confirm_delete", "Delete")
+ .style(ButtonStyle::Tinted(ui::TintColor::Error))
+ .color(Color::Error)
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(|_, _, window, cx| {
+ window.dispatch_action(
+ Box::new(RemoveHistory),
+ cx,
+ );
+ })),
+ ),
+ )
+ }),
+ )
+ })
+ }
+}
+
+#[derive(IntoElement)]
+pub struct HistoryEntryElement {
+ entry: AgentSessionInfo,
+ thread_view: WeakEntity,
+ selected: bool,
+ hovered: bool,
+ supports_delete: bool,
+ on_hover: Box,
+}
+
+impl HistoryEntryElement {
+ pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity) -> Self {
+ Self {
+ entry,
+ thread_view,
+ selected: false,
+ hovered: false,
+ supports_delete: false,
+ on_hover: Box::new(|_, _, _| {}),
+ }
+ }
+
+ pub fn supports_delete(mut self, supports_delete: bool) -> Self {
+ self.supports_delete = supports_delete;
+ self
+ }
+
+ pub fn hovered(mut self, hovered: bool) -> Self {
+ self.hovered = hovered;
+ self
+ }
+
+ pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
+ self.on_hover = Box::new(on_hover);
+ self
+ }
+}
+
+impl RenderOnce for HistoryEntryElement {
+ fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+ let id = ElementId::Name(self.entry.session_id.0.clone().into());
+ let title = thread_title(&self.entry).clone();
+ let formatted_time = self
+ .entry
+ .updated_at
+ .map(|timestamp| {
+ let now = chrono::Utc::now();
+ let duration = now.signed_duration_since(timestamp);
+
+ if duration.num_days() > 0 {
+ format!("{}d", duration.num_days())
+ } else if duration.num_hours() > 0 {
+ format!("{}h ago", duration.num_hours())
+ } else if duration.num_minutes() > 0 {
+ format!("{}m ago", duration.num_minutes())
+ } else {
+ "Just now".to_string()
+ }
+ })
+ .unwrap_or_else(|| "Unknown".to_string());
+
+ ListItem::new(id)
+ .rounded()
+ .toggle_state(self.selected)
+ .spacing(ListItemSpacing::Sparse)
+ .start_slot(
+ h_flex()
+ .w_full()
+ .gap_2()
+ .justify_between()
+ .child(Label::new(title).size(LabelSize::Small).truncate())
+ .child(
+ Label::new(formatted_time)
+ .color(Color::Muted)
+ .size(LabelSize::XSmall),
+ ),
+ )
+ .on_hover(self.on_hover)
+ .end_slot::(if (self.hovered || self.selected) && self.supports_delete {
+ Some(
+ IconButton::new("delete", IconName::Trash)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::XSmall)
+ .icon_color(Color::Muted)
+ .tooltip(move |_window, cx| {
+ Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
+ })
+ .on_click({
+ let thread_view = self.thread_view.clone();
+ let session_id = self.entry.session_id.clone();
+
+ move |_event, _window, cx| {
+ if let Some(thread_view) = thread_view.upgrade() {
+ thread_view.update(cx, |thread_view, cx| {
+ thread_view.delete_history_entry(&session_id, cx);
+ });
+ }
+ }
+ }),
+ )
+ } else {
+ None
+ })
+ .on_click({
+ let thread_view = self.thread_view.clone();
+ let entry = self.entry;
+
+ move |_event, window, cx| {
+ if let Some(workspace) = thread_view
+ .upgrade()
+ .and_then(|view| view.read(cx).workspace().upgrade())
+ {
+ if let Some(panel) = workspace.read(cx).panel::(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.load_agent_thread(
+ entry.session_id.clone(),
+ entry.cwd.clone(),
+ entry.title.clone(),
+ window,
+ cx,
+ );
+ });
+ }
+ }
+ }
+ })
+ }
+}
+
+#[derive(Clone, Copy)]
+pub enum EntryTimeFormat {
+ DateAndTime,
+ TimeOnly,
+}
+
+impl EntryTimeFormat {
+ fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
+ let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
+
+ match self {
+ EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
+ timestamp,
+ OffsetDateTime::now_utc(),
+ timezone,
+ time_format::TimestampFormat::EnhancedAbsolute,
+ ),
+ EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
+ }
+ }
+}
+
+impl From for EntryTimeFormat {
+ fn from(bucket: TimeBucket) -> Self {
+ match bucket {
+ TimeBucket::Today => EntryTimeFormat::TimeOnly,
+ TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
+ TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
+ TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
+ TimeBucket::All => EntryTimeFormat::DateAndTime,
+ }
+ }
+}
+
+#[derive(PartialEq, Eq, Clone, Copy, Debug)]
+enum TimeBucket {
+ Today,
+ Yesterday,
+ ThisWeek,
+ PastWeek,
+ All,
+}
+
+impl TimeBucket {
+ fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
+ if date == reference {
+ return TimeBucket::Today;
+ }
+
+ if date == reference - TimeDelta::days(1) {
+ return TimeBucket::Yesterday;
+ }
+
+ let week = date.iso_week();
+
+ if reference.iso_week() == week {
+ return TimeBucket::ThisWeek;
+ }
+
+ let last_week = (reference - TimeDelta::days(7)).iso_week();
+
+ if week == last_week {
+ return TimeBucket::PastWeek;
+ }
+
+ TimeBucket::All
+ }
+}
+
+impl Display for TimeBucket {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ TimeBucket::Today => write!(f, "Today"),
+ TimeBucket::Yesterday => write!(f, "Yesterday"),
+ TimeBucket::ThisWeek => write!(f, "This Week"),
+ TimeBucket::PastWeek => write!(f, "Past Week"),
+ TimeBucket::All => write!(f, "All"),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::NaiveDate;
+
+ #[test]
+ fn test_time_bucket_from_dates() {
+ let today = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
+
+ assert_eq!(TimeBucket::from_dates(today, today), TimeBucket::Today);
+
+ let yesterday = NaiveDate::from_ymd_opt(2025, 1, 14).unwrap();
+ assert_eq!(
+ TimeBucket::from_dates(today, yesterday),
+ TimeBucket::Yesterday
+ );
+
+ let this_week = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
+ assert_eq!(
+ TimeBucket::from_dates(today, this_week),
+ TimeBucket::ThisWeek
+ );
+
+ let past_week = NaiveDate::from_ymd_opt(2025, 1, 7).unwrap();
+ assert_eq!(
+ TimeBucket::from_dates(today, past_week),
+ TimeBucket::PastWeek
+ );
+
+ let old = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap();
+ assert_eq!(TimeBucket::from_dates(today, old), TimeBucket::All);
+ }
+}
diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs
index 0b1ccb4088e67de332c2bd2940ca5bdf77f1d3df..8b578d2e7f00a4f0dd139e074259d28e09932908 100644
--- a/crates/ai_onboarding/src/ai_onboarding.rs
+++ b/crates/ai_onboarding/src/ai_onboarding.rs
@@ -266,6 +266,20 @@ impl ZedAiOnboarding {
.into_any_element()
}
+ fn render_business_plan_state(&self, _cx: &mut App) -> AnyElement {
+ v_flex()
+ .gap_1()
+ .child(Headline::new("Welcome to Zed Business"))
+ .child(
+ Label::new("Here's what you get:")
+ .color(Color::Muted)
+ .mb_2(),
+ )
+ .child(PlanDefinitions.business_plan())
+ .children(self.render_dismiss_button())
+ .into_any_element()
+ }
+
fn render_student_plan_state(&self, _cx: &mut App) -> AnyElement {
v_flex()
.gap_1()
@@ -289,6 +303,7 @@ impl RenderOnce for ZedAiOnboarding {
Some(Plan::ZedFree) => self.render_free_plan_state(cx),
Some(Plan::ZedProTrial) => self.render_trial_state(cx),
Some(Plan::ZedPro) => self.render_pro_plan_state(cx),
+ Some(Plan::ZedBusiness) => self.render_business_plan_state(cx),
Some(Plan::ZedStudent) => self.render_student_plan_state(cx),
}
} else {
@@ -353,6 +368,10 @@ impl Component for ZedAiOnboarding {
"Pro Plan",
onboarding(SignInStatus::SignedIn, Some(Plan::ZedPro), false),
),
+ single_example(
+ "Business Plan",
+ onboarding(SignInStatus::SignedIn, Some(Plan::ZedBusiness), false),
+ ),
])
.into_any_element(),
)
diff --git a/crates/ai_onboarding/src/ai_upsell_card.rs b/crates/ai_onboarding/src/ai_upsell_card.rs
index f1a1c4310def0b9b4dbabbc6a59eae940396fbb9..40a35f590d87a9928d4299199a99f223264e5ef3 100644
--- a/crates/ai_onboarding/src/ai_upsell_card.rs
+++ b/crates/ai_onboarding/src/ai_upsell_card.rs
@@ -250,6 +250,15 @@ impl RenderOnce for AiUpsellCard {
.mb_2(),
)
.child(PlanDefinitions.pro_plan()),
+ Some(Plan::ZedBusiness) => card
+ .child(certified_user_stamp)
+ .child(Label::new("You're in the Zed Business plan").size(LabelSize::Large))
+ .child(
+ Label::new("Here's what you get:")
+ .color(Color::Muted)
+ .mb_2(),
+ )
+ .child(PlanDefinitions.business_plan()),
Some(Plan::ZedStudent) => card
.child(certified_user_stamp)
.child(Label::new("You're in the Zed Student plan").size(LabelSize::Large))
@@ -368,6 +377,17 @@ impl Component for AiUpsellCard {
}
.into_any_element(),
),
+ single_example(
+ "Business Plan",
+ AiUpsellCard {
+ sign_in_status: SignInStatus::SignedIn,
+ sign_in: Arc::new(|_, _| {}),
+ account_too_young: false,
+ user_plan: Some(Plan::ZedBusiness),
+ tab_index: Some(1),
+ }
+ .into_any_element(),
+ ),
],
))
.into_any_element(),
diff --git a/crates/ai_onboarding/src/plan_definitions.rs b/crates/ai_onboarding/src/plan_definitions.rs
index 6d46a598c385b300fa579c69b0c58cfe51610c68..184815bcad9babb1892335c6207a79e1fe193c04 100644
--- a/crates/ai_onboarding/src/plan_definitions.rs
+++ b/crates/ai_onboarding/src/plan_definitions.rs
@@ -36,6 +36,12 @@ impl PlanDefinitions {
.child(ListBulletItem::new("Usage-based billing beyond $5"))
}
+ pub fn business_plan(&self) -> impl IntoElement {
+ List::new()
+ .child(ListBulletItem::new("Unlimited edit predictions"))
+ .child(ListBulletItem::new("Usage-based billing"))
+ }
+
pub fn student_plan(&self) -> impl IntoElement {
List::new()
.child(ListBulletItem::new("Unlimited edit predictions"))
diff --git a/crates/cloud_api_types/src/plan.rs b/crates/cloud_api_types/src/plan.rs
index e4a33e3c1933717f642848acc13dcf19b173e902..1f40d1ddb5f0e72871d5ecaee62b884132c158e4 100644
--- a/crates/cloud_api_types/src/plan.rs
+++ b/crates/cloud_api_types/src/plan.rs
@@ -9,6 +9,7 @@ pub enum Plan {
ZedFree,
ZedPro,
ZedProTrial,
+ ZedBusiness,
ZedStudent,
}
diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs
index 5e1c9f9a03ec0c4bff0bbd60a9aefc6a06fa5368..63240ddd53108f0b2450386150958e23f975d7ed 100644
--- a/crates/edit_prediction/src/edit_prediction.rs
+++ b/crates/edit_prediction/src/edit_prediction.rs
@@ -23,14 +23,14 @@ use futures::{
use gpui::BackgroundExecutor;
use gpui::http_client::Url;
use gpui::{
- App, AsyncApp, Entity, EntityId, Global, SharedString, Subscription, Task, WeakEntity, actions,
+ App, AsyncApp, Entity, EntityId, Global, SharedString, Task, WeakEntity, actions,
http_client::{self, AsyncBody, Method},
prelude::*,
};
use language::language_settings::all_language_settings;
use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToOffset, ToPoint};
use language::{BufferSnapshot, OffsetRangeExt};
-use language_model::{LlmApiToken, NeedsLlmTokenRefresh, RefreshLlmTokenListener};
+use language_model::{LlmApiToken, NeedsLlmTokenRefresh};
use project::{DisableAiSettings, Project, ProjectPath, WorktreeId};
use release_channel::AppVersion;
use semver::Version;
@@ -133,7 +133,6 @@ pub struct EditPredictionStore {
client: Arc,
user_store: Entity,
llm_token: LlmApiToken,
- _llm_token_subscription: Subscription,
_fetch_experiments_task: Task<()>,
projects: HashMap,
update_required: bool,
@@ -674,10 +673,9 @@ impl EditPredictionStore {
}
pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self {
- let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx);
let data_collection_choice = Self::load_data_collection_choice();
- let llm_token = LlmApiToken::default();
+ let llm_token = LlmApiToken::global(cx);
let (reject_tx, reject_rx) = mpsc::unbounded();
cx.background_spawn({
@@ -721,23 +719,6 @@ impl EditPredictionStore {
user_store,
llm_token,
_fetch_experiments_task: fetch_experiments_task,
- _llm_token_subscription: cx.subscribe(
- &refresh_llm_token_listener,
- |this, _listener, _event, cx| {
- let client = this.client.clone();
- let llm_token = this.llm_token.clone();
- let organization_id = this
- .user_store
- .read(cx)
- .current_organization()
- .map(|organization| organization.id.clone());
- cx.spawn(async move |_this, _cx| {
- llm_token.refresh(&client, organization_id).await?;
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
- },
- ),
update_required: false,
edit_prediction_model: EditPredictionModel::Zeta,
zeta2_raw_config: Self::zeta2_raw_config_from_env(),
diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs
index ad237e6f8fb31708dbabc6e8332ce0c164877004..8f97df2c308980e1c2c89838609b30e1aedb1917 100644
--- a/crates/edit_prediction/src/edit_prediction_tests.rs
+++ b/crates/edit_prediction/src/edit_prediction_tests.rs
@@ -21,6 +21,7 @@ use language::{
Anchor, Buffer, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSeverity,
Operation, Point, Selection, SelectionGoal,
};
+use language_model::RefreshLlmTokenListener;
use lsp::LanguageServerId;
use parking_lot::Mutex;
use pretty_assertions::{assert_eq, assert_matches};
diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs
index ca3dd81ab072d0e20389318515049793a8c827ef..dc2696eb2ca83999934cab6cdee82e364657c70e 100644
--- a/crates/editor/src/editor.rs
+++ b/crates/editor/src/editor.rs
@@ -8389,6 +8389,7 @@ impl Editor {
self.update_hovered_link(
position_map.point_for_position(mouse_position),
+ Some(mouse_position),
&position_map.snapshot,
modifiers,
window,
diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs
index 3b1356525960654ea88c6cfa84115f1e67ac2e5b..5de14d80681ca1ad07534e8764217ef75cc90305 100644
--- a/crates/editor/src/element.rs
+++ b/crates/editor/src/element.rs
@@ -1462,6 +1462,7 @@ impl EditorElement {
if text_hovered {
editor.update_hovered_link(
point_for_position,
+ Some(event.position),
&position_map.snapshot,
modifiers,
window,
@@ -1473,12 +1474,13 @@ impl EditorElement {
.snapshot
.buffer_snapshot()
.anchor_before(point.to_offset(&position_map.snapshot, Bias::Left));
- hover_at(editor, Some(anchor), window, cx);
+ hover_at(editor, Some(anchor), Some(event.position), window, cx);
Self::update_visible_cursor(editor, point, position_map, window, cx);
} else {
editor.update_inlay_link_and_hover_points(
&position_map.snapshot,
point_for_position,
+ Some(event.position),
modifiers.secondary(),
modifiers.shift,
window,
@@ -1487,7 +1489,7 @@ impl EditorElement {
}
} else {
editor.hide_hovered_link(cx);
- hover_at(editor, None, window, cx);
+ hover_at(editor, None, Some(event.position), window, cx);
}
}
diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs
index 659a383d6b20129909b4c3f2d7bdbfbe5e580f4e..3a6ff4ec0e4fc53d19bfb51a10b1f7790933b175 100644
--- a/crates/editor/src/hover_links.rs
+++ b/crates/editor/src/hover_links.rs
@@ -4,7 +4,7 @@ use crate::{
HighlightKey, Navigated, PointForPosition, SelectPhase,
editor_settings::GoToDefinitionFallback, scroll::ScrollAmount,
};
-use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Task, Window, px};
+use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Pixels, Task, Window, px};
use language::{Bias, ToOffset};
use linkify::{LinkFinder, LinkKind};
use lsp::LanguageServerId;
@@ -113,6 +113,7 @@ impl Editor {
pub(crate) fn update_hovered_link(
&mut self,
point_for_position: PointForPosition,
+ mouse_position: Option>,
snapshot: &EditorSnapshot,
modifiers: Modifiers,
window: &mut Window,
@@ -138,6 +139,7 @@ impl Editor {
self.update_inlay_link_and_hover_points(
snapshot,
point_for_position,
+ mouse_position,
hovered_link_modifier,
modifiers.shift,
window,
diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs
index f5d5e6d5ab69d690bd5f3aee29bf9aa493cf0059..ad54d6105ca3896d21857d548d80f991a1a76ecc 100644
--- a/crates/editor/src/hover_popover.rs
+++ b/crates/editor/src/hover_popover.rs
@@ -8,10 +8,10 @@ use crate::{
};
use anyhow::Context as _;
use gpui::{
- AnyElement, AsyncWindowContext, Context, Entity, Focusable as _, FontWeight, Hsla,
- InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size,
- StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TextStyleRefinement,
- Window, div, px,
+ AnyElement, App, AsyncApp, AsyncWindowContext, Bounds, Context, Entity, Focusable as _,
+ FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels,
+ ScrollHandle, Size, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
+ TextStyleRefinement, WeakEntity, Window, canvas, div, px,
};
use itertools::Itertools;
use language::{DiagnosticEntry, Language, LanguageRegistry};
@@ -20,7 +20,10 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint};
use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart};
use settings::Settings;
-use std::{borrow::Cow, cell::RefCell};
+use std::{
+ borrow::Cow,
+ cell::{Cell, RefCell},
+};
use std::{ops::Range, sync::Arc, time::Duration};
use std::{path::PathBuf, rc::Rc};
use theme::ThemeSettings;
@@ -45,6 +48,7 @@ pub fn hover(editor: &mut Editor, _: &Hover, window: &mut Window, cx: &mut Conte
pub fn hover_at(
editor: &mut Editor,
anchor: Option,
+ mouse_position: Option>,
window: &mut Window,
cx: &mut Context,
) {
@@ -52,10 +56,37 @@ pub fn hover_at(
if show_keyboard_hover(editor, window, cx) {
return;
}
+
if let Some(anchor) = anchor {
+ editor.hover_state.hiding_delay_task = None;
+ editor.hover_state.closest_mouse_distance = None;
show_hover(editor, anchor, false, window, cx);
} else {
- hide_hover(editor, cx);
+ let mut getting_closer = false;
+ if let Some(mouse_position) = mouse_position {
+ getting_closer = editor.hover_state.is_mouse_getting_closer(mouse_position);
+ }
+
+ // If we are moving away and a timer is already running, just let it count down.
+ if !getting_closer && editor.hover_state.hiding_delay_task.is_some() {
+ return;
+ }
+
+ // If we are moving closer, or if no timer is running at all, start/restart the 300ms timer.
+ let delay = 300u64;
+ let task = cx.spawn(move |this: WeakEntity, cx: &mut AsyncApp| {
+ let mut cx = cx.clone();
+ async move {
+ cx.background_executor()
+ .timer(Duration::from_millis(delay))
+ .await;
+ this.update(&mut cx, |editor, cx| {
+ hide_hover(editor, cx);
+ })
+ .ok();
+ }
+ });
+ editor.hover_state.hiding_delay_task = Some(task);
}
}
}
@@ -156,6 +187,9 @@ pub fn hover_at_inlay(
let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0;
+ editor.hover_state.hiding_delay_task = None;
+ editor.hover_state.closest_mouse_distance = None;
+
let task = cx.spawn_in(window, async move |this, cx| {
async move {
cx.background_executor()
@@ -187,6 +221,7 @@ pub fn hover_at_inlay(
scroll_handle,
keyboard_grace: Rc::new(RefCell::new(false)),
anchor: None,
+ last_bounds: Rc::new(Cell::new(None)),
_subscription: subscription,
};
@@ -216,6 +251,8 @@ pub fn hide_hover(editor: &mut Editor, cx: &mut Context) -> bool {
editor.hover_state.info_task = None;
editor.hover_state.triggered_from = None;
+ editor.hover_state.hiding_delay_task = None;
+ editor.hover_state.closest_mouse_distance = None;
editor.clear_background_highlights(HighlightKey::HoverState, cx);
@@ -254,6 +291,9 @@ fn show_hover(
.map(|project| project.read(cx).languages().clone());
let provider = editor.semantics_provider.clone()?;
+ editor.hover_state.hiding_delay_task = None;
+ editor.hover_state.closest_mouse_distance = None;
+
if !ignore_timeout {
if same_info_hover(editor, &snapshot, anchor)
|| same_diagnostic_hover(editor, &snapshot, anchor)
@@ -398,6 +438,7 @@ fn show_hover(
background_color,
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
anchor,
+ last_bounds: Rc::new(Cell::new(None)),
_subscription: subscription,
})
} else {
@@ -466,6 +507,7 @@ fn show_hover(
scroll_handle,
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
anchor: Some(anchor),
+ last_bounds: Rc::new(Cell::new(None)),
_subscription: subscription,
})
}
@@ -507,6 +549,7 @@ fn show_hover(
scroll_handle,
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
anchor: Some(anchor),
+ last_bounds: Rc::new(Cell::new(None)),
_subscription: subscription,
});
}
@@ -778,6 +821,8 @@ pub struct HoverState {
pub diagnostic_popover: Option,
pub triggered_from: Option,
pub info_task: Option>>,
+ pub closest_mouse_distance: Option,
+ pub hiding_delay_task: Option>,
}
impl HoverState {
@@ -785,6 +830,60 @@ impl HoverState {
!self.info_popovers.is_empty() || self.diagnostic_popover.is_some()
}
+ pub fn is_mouse_getting_closer(&mut self, mouse_position: gpui::Point) -> bool {
+ if !self.visible() {
+ return false;
+ }
+
+ let mut popover_bounds = Vec::new();
+ for info_popover in &self.info_popovers {
+ if let Some(bounds) = info_popover.last_bounds.get() {
+ popover_bounds.push(bounds);
+ }
+ }
+ if let Some(diagnostic_popover) = &self.diagnostic_popover {
+ if let Some(bounds) = diagnostic_popover.last_bounds.get() {
+ popover_bounds.push(bounds);
+ }
+ }
+
+ if popover_bounds.is_empty() {
+ return false;
+ }
+
+ let distance = popover_bounds
+ .iter()
+ .map(|bounds| self.distance_from_point_to_bounds(mouse_position, *bounds))
+ .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
+ .unwrap_or(px(f32::MAX));
+
+ if let Some(closest_distance) = self.closest_mouse_distance {
+ if distance > closest_distance + px(4.0) {
+ return false;
+ }
+ }
+
+ self.closest_mouse_distance =
+ Some(distance.min(self.closest_mouse_distance.unwrap_or(distance)));
+ true
+ }
+
+ fn distance_from_point_to_bounds(
+ &self,
+ point: gpui::Point,
+ bounds: Bounds,
+ ) -> Pixels {
+ let center_x = bounds.origin.x + bounds.size.width / 2.;
+ let center_y = bounds.origin.y + bounds.size.height / 2.;
+ let dx: f32 = ((point.x - center_x).abs() - bounds.size.width / 2.)
+ .max(px(0.0))
+ .into();
+ let dy: f32 = ((point.y - center_y).abs() - bounds.size.height / 2.)
+ .max(px(0.0))
+ .into();
+ px((dx.powi(2) + dy.powi(2)).sqrt())
+ }
+
pub(crate) fn render(
&mut self,
snapshot: &EditorSnapshot,
@@ -887,6 +986,7 @@ pub struct InfoPopover {
pub scroll_handle: ScrollHandle,
pub keyboard_grace: Rc>,
pub anchor: Option,
+ pub last_bounds: Rc>>>,
_subscription: Option,
}
@@ -898,13 +998,36 @@ impl InfoPopover {
cx: &mut Context,
) -> AnyElement {
let keyboard_grace = Rc::clone(&self.keyboard_grace);
+ let this = cx.entity().downgrade();
+ let bounds_cell = self.last_bounds.clone();
div()
.id("info_popover")
.occlude()
.elevation_2(cx)
+ .child(
+ canvas(
+ {
+ move |bounds, _window, _cx| {
+ bounds_cell.set(Some(bounds));
+ }
+ },
+ |_, _, _, _| {},
+ )
+ .absolute()
+ .size_full(),
+ )
// Prevent a mouse down/move on the popover from being propagated to the editor,
// because that would dismiss the popover.
- .on_mouse_move(|_, _, cx| cx.stop_propagation())
+ .on_mouse_move({
+ move |_, _, cx: &mut App| {
+ this.update(cx, |editor, _| {
+ editor.hover_state.closest_mouse_distance = Some(px(0.0));
+ editor.hover_state.hiding_delay_task = None;
+ })
+ .ok();
+ cx.stop_propagation()
+ }
+ })
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
let mut keyboard_grace = keyboard_grace.borrow_mut();
*keyboard_grace = false;
@@ -957,6 +1080,7 @@ pub struct DiagnosticPopover {
background_color: Hsla,
pub keyboard_grace: Rc>,
pub anchor: Anchor,
+ pub last_bounds: Rc| >>>,
_subscription: Subscription,
pub scroll_handle: ScrollHandle,
}
@@ -970,10 +1094,23 @@ impl DiagnosticPopover {
) -> AnyElement {
let keyboard_grace = Rc::clone(&self.keyboard_grace);
let this = cx.entity().downgrade();
+ let bounds_cell = self.last_bounds.clone();
div()
.id("diagnostic")
.occlude()
.elevation_2_borderless(cx)
+ .child(
+ canvas(
+ {
+ move |bounds, _window, _cx| {
+ bounds_cell.set(Some(bounds));
+ }
+ },
+ |_, _, _, _| {},
+ )
+ .absolute()
+ .size_full(),
+ )
// Don't draw the background color if the theme
// allows transparent surfaces.
.when(theme_is_transparent(cx), |this| {
@@ -981,7 +1118,17 @@ impl DiagnosticPopover {
})
// Prevent a mouse move on the popover from being propagated to the editor,
// because that would dismiss the popover.
- .on_mouse_move(|_, _, cx| cx.stop_propagation())
+ .on_mouse_move({
+ let this = this.clone();
+ move |_, _, cx: &mut App| {
+ this.update(cx, |editor, _| {
+ editor.hover_state.closest_mouse_distance = Some(px(0.0));
+ editor.hover_state.hiding_delay_task = None;
+ })
+ .ok();
+ cx.stop_propagation()
+ }
+ })
// Prevent a mouse down on the popover from being propagated to the editor,
// because that would move the cursor.
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
@@ -1151,7 +1298,7 @@ mod tests {
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
- hover_at(editor, Some(anchor), window, cx)
+ hover_at(editor, Some(anchor), None, window, cx)
});
assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible()));
@@ -1251,7 +1398,7 @@ mod tests {
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
- hover_at(editor, Some(anchor), window, cx)
+ hover_at(editor, Some(anchor), None, window, cx)
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
@@ -1289,7 +1436,7 @@ mod tests {
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
- hover_at(editor, Some(anchor), window, cx)
+ hover_at(editor, Some(anchor), None, window, cx)
});
assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible()));
@@ -1343,7 +1490,7 @@ mod tests {
let anchor = snapshot
.buffer_snapshot()
.anchor_before(hover_point.to_offset(&snapshot, Bias::Left));
- hover_at(editor, Some(anchor), window, cx)
+ hover_at(editor, Some(anchor), None, window, cx)
});
cx.background_executor
.advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100));
@@ -1752,6 +1899,7 @@ mod tests {
editor.update_inlay_link_and_hover_points(
&editor.snapshot(window, cx),
new_type_hint_part_hover_position,
+ None,
true,
false,
window,
@@ -1822,6 +1970,7 @@ mod tests {
editor.update_inlay_link_and_hover_points(
&editor.snapshot(window, cx),
new_type_hint_part_hover_position,
+ None,
true,
false,
window,
@@ -1877,6 +2026,7 @@ mod tests {
editor.update_inlay_link_and_hover_points(
&editor.snapshot(window, cx),
struct_hint_part_hover_position,
+ None,
true,
false,
window,
diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs
index 62eb35f1ac85227c9b52737660da0d1834e1bbfa..414829dc3bbcd89f5f4e4337a955cfff5bb57fca 100644
--- a/crates/editor/src/inlays/inlay_hints.rs
+++ b/crates/editor/src/inlays/inlay_hints.rs
@@ -7,7 +7,7 @@ use std::{
use clock::Global;
use collections::{HashMap, HashSet};
use futures::future::join_all;
-use gpui::{App, Entity, Task};
+use gpui::{App, Entity, Pixels, Task};
use itertools::Itertools;
use language::{
BufferRow,
@@ -569,6 +569,7 @@ impl Editor {
&mut self,
snapshot: &EditorSnapshot,
point_for_position: PointForPosition,
+ mouse_position: Option>,
secondary_held: bool,
shift_held: bool,
window: &mut Window,
@@ -748,7 +749,7 @@ impl Editor {
self.hide_hovered_link(cx)
}
if !hover_updated {
- hover_popover::hover_at(self, None, window, cx);
+ hover_popover::hover_at(self, None, mouse_position, window, cx);
}
}
diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs
index 3599affc3c792f3c93b3b94cfc44740d7c38caf7..bf185b1b6cc20e0f0f484fd0029c78a6211e6a3a 100644
--- a/crates/gpui/src/elements/div.rs
+++ b/crates/gpui/src/elements/div.rs
@@ -15,6 +15,8 @@
//! and Tailwind-like styling that you can use to build your own custom elements. Div is
//! constructed by combining these two systems into an all-in-one element.
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+use crate::PinchEvent;
use crate::{
AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent,
DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId,
@@ -353,6 +355,43 @@ impl Interactivity {
}));
}
+ /// Bind the given callback to pinch gesture events during the bubble phase.
+ ///
+ /// Note: This event is only available on macOS and Wayland (Linux).
+ /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
+ ///
+ /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub fn on_pinch(&mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) {
+ self.pinch_listeners
+ .push(Box::new(move |event, phase, hitbox, window, cx| {
+ if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) {
+ (listener)(event, window, cx);
+ }
+ }));
+ }
+
+ /// Bind the given callback to pinch gesture events during the capture phase.
+ ///
+ /// Note: This event is only available on macOS and Wayland (Linux).
+ /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
+ ///
+ /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub fn capture_pinch(
+ &mut self,
+ listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static,
+ ) {
+ self.pinch_listeners
+ .push(Box::new(move |event, phase, _hitbox, window, cx| {
+ if phase == DispatchPhase::Capture {
+ (listener)(event, window, cx);
+ } else {
+ cx.propagate();
+ }
+ }));
+ }
+
/// Bind the given callback to an action dispatch during the capture phase.
/// The imperative API equivalent to [`InteractiveElement::capture_action`].
///
@@ -635,6 +674,16 @@ impl Interactivity {
pub fn block_mouse_except_scroll(&mut self) {
self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll;
}
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ fn has_pinch_listeners(&self) -> bool {
+ !self.pinch_listeners.is_empty()
+ }
+
+ #[cfg(not(any(target_os = "linux", target_os = "macos")))]
+ fn has_pinch_listeners(&self) -> bool {
+ false
+ }
}
/// A trait for elements that want to use the standard GPUI event handlers that don't
@@ -905,6 +954,34 @@ pub trait InteractiveElement: Sized {
self
}
+ /// Bind the given callback to pinch gesture events during the bubble phase.
+ /// The fluent API equivalent to [`Interactivity::on_pinch`].
+ ///
+ /// Note: This event is only available on macOS and Wayland (Linux).
+ /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
+ ///
+ /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ fn on_pinch(mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) -> Self {
+ self.interactivity().on_pinch(listener);
+ self
+ }
+
+ /// Bind the given callback to pinch gesture events during the capture phase.
+ /// The fluent API equivalent to [`Interactivity::capture_pinch`].
+ ///
+ /// Note: This event is only available on macOS and Wayland (Linux).
+ /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
+ ///
+ /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ fn capture_pinch(
+ mut self,
+ listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.interactivity().capture_pinch(listener);
+ self
+ }
/// Capture the given action, before normal action dispatch can fire.
/// The fluent API equivalent to [`Interactivity::capture_action`].
///
@@ -1290,6 +1367,10 @@ pub(crate) type MouseMoveListener =
pub(crate) type ScrollWheelListener =
Box;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) type PinchListener =
+ Box;
+
pub(crate) type ClickListener = Rc;
pub(crate) type DragListener =
@@ -1644,6 +1725,8 @@ pub struct Interactivity {
pub(crate) mouse_pressure_listeners: Vec,
pub(crate) mouse_move_listeners: Vec,
pub(crate) scroll_wheel_listeners: Vec,
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) pinch_listeners: Vec,
pub(crate) key_down_listeners: Vec,
pub(crate) key_up_listeners: Vec,
pub(crate) modifiers_changed_listeners: Vec,
@@ -1847,6 +1930,7 @@ impl Interactivity {
|| !self.click_listeners.is_empty()
|| !self.aux_click_listeners.is_empty()
|| !self.scroll_wheel_listeners.is_empty()
+ || self.has_pinch_listeners()
|| self.drag_listener.is_some()
|| !self.drop_listeners.is_empty()
|| self.tooltip_builder.is_some()
@@ -2213,6 +2297,14 @@ impl Interactivity {
})
}
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ for listener in self.pinch_listeners.drain(..) {
+ let hitbox = hitbox.clone();
+ window.on_mouse_event(move |event: &PinchEvent, phase, window, cx| {
+ listener(event, phase, &hitbox, window, cx);
+ })
+ }
+
if self.hover_style.is_some()
|| self.base_style.mouse_cursor.is_some()
|| cx.active_drag.is_some() && !self.drag_over_styles.is_empty()
diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs
index 5316a5992bb41d11ef5b6518555a9a20795f894c..3d3ddb49f70b2f96772627d085c93ce31b6dc0b5 100644
--- a/crates/gpui/src/interactive.rs
+++ b/crates/gpui/src/interactive.rs
@@ -17,6 +17,9 @@ pub trait KeyEvent: InputEvent {}
/// A mouse event from the platform.
pub trait MouseEvent: InputEvent {}
+/// A gesture event from the platform.
+pub trait GestureEvent: InputEvent {}
+
/// The key down event equivalent for the platform.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct KeyDownEvent {
@@ -467,6 +470,51 @@ impl Default for ScrollDelta {
}
}
+/// A pinch gesture event from the platform, generated when the user performs
+/// a pinch-to-zoom gesture (typically on a trackpad).
+///
+/// Note: This event is only available on macOS and Wayland (Linux).
+/// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held.
+#[derive(Clone, Debug, Default)]
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub struct PinchEvent {
+ /// The position of the pinch center on the window.
+ pub position: Point,
+
+ /// The zoom delta for this event.
+ /// Positive values indicate zooming in, negative values indicate zooming out.
+ /// For example, 0.1 represents a 10% zoom increase.
+ pub delta: f32,
+
+ /// The modifiers that were held down during the pinch gesture.
+ pub modifiers: Modifiers,
+
+ /// The phase of the pinch gesture.
+ pub phase: TouchPhase,
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl Sealed for PinchEvent {}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl InputEvent for PinchEvent {
+ fn to_platform_input(self) -> PlatformInput {
+ PlatformInput::Pinch(self)
+ }
+}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl GestureEvent for PinchEvent {}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl MouseEvent for PinchEvent {}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl Deref for PinchEvent {
+ type Target = Modifiers;
+
+ fn deref(&self) -> &Self::Target {
+ &self.modifiers
+ }
+}
+
impl ScrollDelta {
/// Returns true if this is a precise scroll delta in pixels.
pub fn precise(&self) -> bool {
@@ -626,6 +674,9 @@ pub enum PlatformInput {
MouseExited(MouseExitEvent),
/// The scroll wheel was used.
ScrollWheel(ScrollWheelEvent),
+ /// A pinch gesture was performed.
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ Pinch(PinchEvent),
/// Files were dragged and dropped onto the window.
FileDrop(FileDropEvent),
}
@@ -642,6 +693,8 @@ impl PlatformInput {
PlatformInput::MousePressure(event) => Some(event),
PlatformInput::MouseExited(event) => Some(event),
PlatformInput::ScrollWheel(event) => Some(event),
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ PlatformInput::Pinch(event) => Some(event),
PlatformInput::FileDrop(event) => Some(event),
}
}
@@ -657,6 +710,8 @@ impl PlatformInput {
PlatformInput::MousePressure(_) => None,
PlatformInput::MouseExited(_) => None,
PlatformInput::ScrollWheel(_) => None,
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ PlatformInput::Pinch(_) => None,
PlatformInput::FileDrop(_) => None,
}
}
diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs
index 3fcb911d2c58f8968bc6b0c66f26ed2de365dd53..e3c61a4fd31f35df591f20075221907270e352c8 100644
--- a/crates/gpui/src/window.rs
+++ b/crates/gpui/src/window.rs
@@ -3945,6 +3945,12 @@ impl Window {
self.modifiers = scroll_wheel.modifiers;
PlatformInput::ScrollWheel(scroll_wheel)
}
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ PlatformInput::Pinch(pinch) => {
+ self.mouse_position = pinch.position;
+ self.modifiers = pinch.modifiers;
+ PlatformInput::Pinch(pinch)
+ }
// Translate dragging and dropping of external files from the operating system
// to internal drag and drop events.
PlatformInput::FileDrop(file_drop) => match file_drop {
diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs
index 8dd48b878cc1ffcb87201e9b1b252966bfce5efb..ce49fca37232f256e570f584272519d8d6f34dd8 100644
--- a/crates/gpui_linux/src/linux/wayland/client.rs
+++ b/crates/gpui_linux/src/linux/wayland/client.rs
@@ -36,6 +36,9 @@ use wayland_client::{
wl_shm_pool, wl_surface,
},
};
+use wayland_protocols::wp::pointer_gestures::zv1::client::{
+ zwp_pointer_gesture_pinch_v1, zwp_pointer_gestures_v1,
+};
use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{
self, ZwpPrimarySelectionOfferV1,
};
@@ -124,6 +127,7 @@ pub struct Globals {
pub layer_shell: Option,
pub blur_manager: Option,
pub text_input_manager: Option,
+ pub gesture_manager: Option,
pub dialog: Option,
pub executor: ForegroundExecutor,
}
@@ -164,6 +168,7 @@ impl Globals {
layer_shell: globals.bind(&qh, 1..=5, ()).ok(),
blur_manager: globals.bind(&qh, 1..=1, ()).ok(),
text_input_manager: globals.bind(&qh, 1..=1, ()).ok(),
+ gesture_manager: globals.bind(&qh, 1..=3, ()).ok(),
dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(),
executor,
qh,
@@ -208,6 +213,8 @@ pub(crate) struct WaylandClientState {
pub compositor_gpu: Option,
wl_seat: wl_seat::WlSeat, // TODO: Multi seat support
wl_pointer: Option,
+ pinch_gesture: Option,
+ pinch_scale: f32,
wl_keyboard: Option,
cursor_shape_device: Option,
data_device: Option,
@@ -584,6 +591,8 @@ impl WaylandClient {
wl_seat: seat,
wl_pointer: None,
wl_keyboard: None,
+ pinch_gesture: None,
+ pinch_scale: 1.0,
cursor_shape_device: None,
data_device,
primary_selection,
@@ -1325,6 +1334,12 @@ impl Dispatch for WaylandClientStatePtr {
.as_ref()
.map(|cursor_shape_manager| cursor_shape_manager.get_pointer(&pointer, qh, ()));
+ state.pinch_gesture = state.globals.gesture_manager.as_ref().map(
+ |gesture_manager: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1| {
+ gesture_manager.get_pinch_gesture(&pointer, qh, ())
+ },
+ );
+
if let Some(wl_pointer) = &state.wl_pointer {
wl_pointer.release();
}
@@ -1998,6 +2013,91 @@ impl Dispatch for WaylandClientStatePtr {
}
}
+impl Dispatch for WaylandClientStatePtr {
+ fn event(
+ _this: &mut Self,
+ _: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1,
+ _: ::Event,
+ _: &(),
+ _: &Connection,
+ _: &QueueHandle,
+ ) {
+ // The gesture manager doesn't generate events
+ }
+}
+
+impl Dispatch
+ for WaylandClientStatePtr
+{
+ fn event(
+ this: &mut Self,
+ _: &zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1,
+ event: ::Event,
+ _: &(),
+ _: &Connection,
+ _: &QueueHandle,
+ ) {
+ use gpui::PinchEvent;
+
+ let client = this.get_client();
+ let mut state = client.borrow_mut();
+
+ let Some(window) = state.mouse_focused_window.clone() else {
+ return;
+ };
+
+ match event {
+ zwp_pointer_gesture_pinch_v1::Event::Begin {
+ serial: _,
+ time: _,
+ surface: _,
+ fingers: _,
+ } => {
+ state.pinch_scale = 1.0;
+ let input = PlatformInput::Pinch(PinchEvent {
+ position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))),
+ delta: 0.0,
+ modifiers: state.modifiers,
+ phase: TouchPhase::Started,
+ });
+ drop(state);
+ window.handle_input(input);
+ }
+ zwp_pointer_gesture_pinch_v1::Event::Update { time: _, scale, .. } => {
+ let new_absolute_scale = scale as f32;
+ let previous_scale = state.pinch_scale;
+ let zoom_delta = new_absolute_scale - previous_scale;
+ state.pinch_scale = new_absolute_scale;
+
+ let input = PlatformInput::Pinch(PinchEvent {
+ position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))),
+ delta: zoom_delta,
+ modifiers: state.modifiers,
+ phase: TouchPhase::Moved,
+ });
+ drop(state);
+ window.handle_input(input);
+ }
+ zwp_pointer_gesture_pinch_v1::Event::End {
+ serial: _,
+ time: _,
+ cancelled: _,
+ } => {
+ state.pinch_scale = 1.0;
+ let input = PlatformInput::Pinch(PinchEvent {
+ position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))),
+ delta: 0.0,
+ modifiers: state.modifiers,
+ phase: TouchPhase::Ended,
+ });
+ drop(state);
+ window.handle_input(input);
+ }
+ _ => {}
+ }
+ }
+}
+
impl Dispatch for WaylandClientStatePtr {
fn event(
this: &mut Self,
diff --git a/crates/gpui_macos/src/events.rs b/crates/gpui_macos/src/events.rs
index 5970488a17fbf9395f4ba29f5b98a135f6d55f7f..71bcb105e8aa8c6c43fd5b7864881535454c5ec3 100644
--- a/crates/gpui_macos/src/events.rs
+++ b/crates/gpui_macos/src/events.rs
@@ -1,8 +1,8 @@
use gpui::{
Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton,
MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent,
- NavigationDirection, Pixels, PlatformInput, PressureStage, ScrollDelta, ScrollWheelEvent,
- TouchPhase, point, px,
+ NavigationDirection, PinchEvent, Pixels, PlatformInput, PressureStage, ScrollDelta,
+ ScrollWheelEvent, TouchPhase, point, px,
};
use crate::{
@@ -234,6 +234,27 @@ pub(crate) unsafe fn platform_input_from_native(
_ => None,
}
}
+ NSEventType::NSEventTypeMagnify => window_height.map(|window_height| {
+ let phase = match native_event.phase() {
+ NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => {
+ TouchPhase::Started
+ }
+ NSEventPhase::NSEventPhaseEnded => TouchPhase::Ended,
+ _ => TouchPhase::Moved,
+ };
+
+ let magnification = native_event.magnification() as f32;
+
+ PlatformInput::Pinch(PinchEvent {
+ position: point(
+ px(native_event.locationInWindow().x as f32),
+ window_height - px(native_event.locationInWindow().y as f32),
+ ),
+ delta: magnification,
+ modifiers: read_modifiers(native_event),
+ phase,
+ })
+ }),
NSEventType::NSScrollWheel => window_height.map(|window_height| {
let phase = match native_event.phase() {
NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => {
diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs
index 456ee31ac3b03780e68267621d66435b1ceab4a9..c20c86026a102464343fc7c8cfb03b69b19b7641 100644
--- a/crates/gpui_macos/src/window.rs
+++ b/crates/gpui_macos/src/window.rs
@@ -172,6 +172,10 @@ unsafe fn build_classes() {
sel!(mouseExited:),
handle_view_event as extern "C" fn(&Object, Sel, id),
);
+ decl.add_method(
+ sel!(magnifyWithEvent:),
+ handle_view_event as extern "C" fn(&Object, Sel, id),
+ );
decl.add_method(
sel!(mouseDragged:),
handle_view_event as extern "C" fn(&Object, Sel, id),
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index 7c06eaef92ece60e8b4a9ad78976b68aee854226..94fed7f03f46e64ef0ac929e60cf6ae848145e72 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -244,6 +244,10 @@ pub enum IconName {
ThinkingModeOff,
Thread,
ThreadFromSummary,
+ ThreadsSidebarLeftClosed,
+ ThreadsSidebarLeftOpen,
+ ThreadsSidebarRightClosed,
+ ThreadsSidebarRightOpen,
ThumbsDown,
ThumbsUp,
TodoComplete,
@@ -272,8 +276,6 @@ pub enum IconName {
UserRoundPen,
Warning,
WholeWord,
- WorkspaceNavClosed,
- WorkspaceNavOpen,
XCircle,
XCircleFilled,
ZedAgent,
diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs
index c223494bd709217439bdff9f6a7ba17e1a65494e..291603b2b3f1544f6c60f9c3bdbbb87d3f77c424 100644
--- a/crates/image_viewer/src/image_viewer.rs
+++ b/crates/image_viewer/src/image_viewer.rs
@@ -6,6 +6,8 @@ use std::path::Path;
use anyhow::Context as _;
use editor::{EditorSettings, items::entry_git_aware_label_color};
use file_icons::FileIcons;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+use gpui::PinchEvent;
use gpui::{
AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter,
FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement,
@@ -260,6 +262,12 @@ impl ImageView {
cx.notify();
}
}
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ fn handle_pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context) {
+ let zoom_factor = 1.0 + event.delta;
+ self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx);
+ }
}
struct ImageContentElement {
@@ -679,8 +687,9 @@ impl Render for ImageView {
.size_full()
.relative()
.bg(cx.theme().colors().editor_background)
- .child(
- div()
+ .child({
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ let container = div()
.id("image-container")
.size_full()
.overflow_hidden()
@@ -690,13 +699,34 @@ impl Render for ImageView {
gpui::CursorStyle::OpenHand
})
.on_scroll_wheel(cx.listener(Self::handle_scroll_wheel))
+ .on_pinch(cx.listener(Self::handle_pinch))
.on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down))
.on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down))
.on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up))
.on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up))
.on_mouse_move(cx.listener(Self::handle_mouse_move))
- .child(ImageContentElement::new(cx.entity())),
- )
+ .child(ImageContentElement::new(cx.entity()));
+
+ #[cfg(not(any(target_os = "linux", target_os = "macos")))]
+ let container = div()
+ .id("image-container")
+ .size_full()
+ .overflow_hidden()
+ .cursor(if self.is_dragging() {
+ gpui::CursorStyle::ClosedHand
+ } else {
+ gpui::CursorStyle::OpenHand
+ })
+ .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel))
+ .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down))
+ .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down))
+ .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up))
+ .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up))
+ .on_mouse_move(cx.listener(Self::handle_mouse_move))
+ .child(ImageContentElement::new(cx.entity()));
+
+ container
+ })
}
}
diff --git a/crates/language_model/src/model/mod.rs b/crates/language_model/src/model.rs
similarity index 100%
rename from crates/language_model/src/model/mod.rs
rename to crates/language_model/src/model.rs
diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs
index e64cc43edd8eef6cfaf0c6c966365c81d37b611c..e384ce05fa390677529235442c4cb91186520a02 100644
--- a/crates/language_model/src/model/cloud_model.rs
+++ b/crates/language_model/src/model/cloud_model.rs
@@ -30,6 +30,13 @@ impl fmt::Display for PaymentRequiredError {
pub struct LlmApiToken(Arc>>);
impl LlmApiToken {
+ pub fn global(cx: &App) -> Self {
+ RefreshLlmTokenListener::global(cx)
+ .read(cx)
+ .llm_api_token
+ .clone()
+ }
+
pub async fn acquire(
&self,
client: &Arc,
@@ -102,13 +109,16 @@ struct GlobalRefreshLlmTokenListener(Entity);
impl Global for GlobalRefreshLlmTokenListener {}
-pub struct RefreshLlmTokenEvent;
+pub struct LlmTokenRefreshedEvent;
pub struct RefreshLlmTokenListener {
+ client: Arc,
+ user_store: Entity,
+ llm_api_token: LlmApiToken,
_subscription: Subscription,
}
-impl EventEmitter for RefreshLlmTokenListener {}
+impl EventEmitter for RefreshLlmTokenListener {}
impl RefreshLlmTokenListener {
pub fn register(client: Arc, user_store: Entity, cx: &mut App) {
@@ -128,21 +138,39 @@ impl RefreshLlmTokenListener {
}
});
- let subscription = cx.subscribe(&user_store, |_this, _user_store, event, cx| {
+ let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| {
if matches!(event, client::user::Event::OrganizationChanged) {
- cx.emit(RefreshLlmTokenEvent);
+ this.refresh(cx);
}
});
Self {
+ client,
+ user_store,
+ llm_api_token: LlmApiToken::default(),
_subscription: subscription,
}
}
+ fn refresh(&self, cx: &mut Context) {
+ let client = self.client.clone();
+ let llm_api_token = self.llm_api_token.clone();
+ let organization_id = self
+ .user_store
+ .read(cx)
+ .current_organization()
+ .map(|o| o.id.clone());
+ cx.spawn(async move |this, cx| {
+ llm_api_token.refresh(&client, organization_id).await?;
+ this.update(cx, |_this, cx| cx.emit(LlmTokenRefreshedEvent))
+ })
+ .detach_and_log_err(cx);
+ }
+
fn handle_refresh_llm_token(this: Entity, message: &MessageToClient, cx: &mut App) {
match message {
MessageToClient::UserUpdated => {
- this.update(cx, |_this, cx| cx.emit(RefreshLlmTokenEvent));
+ this.update(cx, |this, cx| this.refresh(cx));
}
}
}
diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs
index 4e705a8d62a5446b17bcc95a7dc75152b0c3269c..610b0167b86f8bf4426b671cedad45a28c3fdc6d 100644
--- a/crates/language_models/src/provider/cloud.rs
+++ b/crates/language_models/src/provider/cloud.rs
@@ -109,9 +109,10 @@ impl State {
cx: &mut Context,
) -> Self {
let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx);
+ let llm_api_token = LlmApiToken::global(cx);
Self {
client: client.clone(),
- llm_api_token: LlmApiToken::default(),
+ llm_api_token,
user_store: user_store.clone(),
status,
models: Vec::new(),
@@ -158,9 +159,6 @@ impl State {
.current_organization()
.map(|o| o.id.clone());
cx.spawn(async move |this, cx| {
- llm_api_token
- .refresh(&client, organization_id.clone())
- .await?;
let response =
Self::fetch_models(client, llm_api_token, organization_id).await?;
this.update(cx, |this, cx| {
diff --git a/crates/languages/src/tsx/brackets.scm b/crates/languages/src/tsx/brackets.scm
index d72fcb26005a0021907558bbbee7471cfeaec603..cd59d553783f685775e45ba883210272b168c3b8 100644
--- a/crates/languages/src/tsx/brackets.scm
+++ b/crates/languages/src/tsx/brackets.scm
@@ -7,14 +7,17 @@
("{" @open
"}" @close)
-("<" @open
+(("<" @open
">" @close)
+ (#set! rainbow.exclude))
-("<" @open
+(("<" @open
"/>" @close)
+ (#set! rainbow.exclude))
-("" @open
+(("" @open
">" @close)
+ (#set! rainbow.exclude))
(("\"" @open
"\"" @close)
diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs
index f62de78b4f9fb702f03943b06270abb41aa68e34..88ebdfd389498ae00ad434eb22726a84a5fe1e01 100644
--- a/crates/livekit_client/src/livekit_client/playback.rs
+++ b/crates/livekit_client/src/livekit_client/playback.rs
@@ -111,7 +111,7 @@ impl AudioStack {
source.num_channels as i32,
);
- let receive_task = self.executor.spawn({
+ let receive_task = self.executor.spawn_with_priority(Priority::RealtimeAudio, {
let source = source.clone();
async move {
while let Some(frame) = stream.next().await {
@@ -202,7 +202,7 @@ impl AudioStack {
let apm = self.apm.clone();
let (frame_tx, mut frame_rx) = futures::channel::mpsc::unbounded();
- let transmit_task = self.executor.spawn({
+ let transmit_task = self.executor.spawn_with_priority(Priority::RealtimeAudio, {
async move {
while let Some(frame) = frame_rx.next().await {
source.capture_frame(&frame).await.log_err();
diff --git a/crates/title_bar/src/plan_chip.rs b/crates/title_bar/src/plan_chip.rs
index edec0da2dea317bd122ece14d6afb90a31990c96..237e507ed8e4d1a5f63a7df116bf08fd69086bc2 100644
--- a/crates/title_bar/src/plan_chip.rs
+++ b/crates/title_bar/src/plan_chip.rs
@@ -33,6 +33,7 @@ impl RenderOnce for PlanChip {
Plan::ZedFree => ("Free", Color::Default, free_chip_bg),
Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg),
Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg),
+ Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg),
Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg),
};
diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs
index edc685159f5c9edc5fa872e9d453d0b81fa9cb16..5be91e9d98a1219dcfbbba70a5541ba7b827cfc5 100644
--- a/crates/ui/src/components/ai/thread_item.rs
+++ b/crates/ui/src/components/ai/thread_item.rs
@@ -1,6 +1,6 @@
use crate::{
- DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration, IconDecorationKind,
- SpinnerLabel, prelude::*,
+ CommonAnimationExt, DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration,
+ IconDecorationKind, prelude::*,
};
use gpui::{AnyView, ClickEvent, Hsla, SharedString};
@@ -26,6 +26,7 @@ pub struct ThreadItem {
selected: bool,
focused: bool,
hovered: bool,
+ docked_right: bool,
added: Option,
removed: Option,
worktree: Option,
@@ -50,6 +51,7 @@ impl ThreadItem {
selected: false,
focused: false,
hovered: false,
+ docked_right: false,
added: None,
removed: None,
worktree: None,
@@ -107,6 +109,11 @@ impl ThreadItem {
self
}
+ pub fn docked_right(mut self, docked_right: bool) -> Self {
+ self.docked_right = docked_right;
+ self
+ }
+
pub fn worktree(mut self, worktree: impl Into) -> Self {
self.worktree = Some(worktree.into());
self
@@ -154,12 +161,12 @@ impl ThreadItem {
impl RenderOnce for ThreadItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let color = cx.theme().colors();
- // let dot_separator = || {
- // Label::new("•")
- // .size(LabelSize::Small)
- // .color(Color::Muted)
- // .alpha(0.5)
- // };
+ let dot_separator = || {
+ Label::new("•")
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .alpha(0.5)
+ };
let icon_container = || h_flex().size_4().flex_none().justify_center();
let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
@@ -194,17 +201,23 @@ impl RenderOnce for ThreadItem {
None
};
- let icon = if let Some(decoration) = decoration {
- icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration)))
- } else {
- icon_container().child(agent_icon)
- };
-
let is_running = matches!(
self.status,
AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
);
- let running_or_action = is_running || (self.hovered && self.action_slot.is_some());
+
+ let icon = if is_running {
+ icon_container().child(
+ Icon::new(IconName::LoadCircle)
+ .size(IconSize::Small)
+ .color(Color::Muted)
+ .with_rotate_animation(2),
+ )
+ } else if let Some(decoration) = decoration {
+ icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration)))
+ } else {
+ icon_container().child(agent_icon)
+ };
let title = self.title;
let highlight_positions = self.highlight_positions;
@@ -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(
diff --git a/crates/ui/src/components/chip.rs b/crates/ui/src/components/chip.rs
index ce709fe3962f742f5208808315f3bdac09c1f513..06dc7e6afa6fa8723985913dfece4205e360511e 100644
--- a/crates/ui/src/components/chip.rs
+++ b/crates/ui/src/components/chip.rs
@@ -81,8 +81,7 @@ impl RenderOnce for Chip {
h_flex()
.when_some(self.height, |this, h| this.h(h))
- .min_w_0()
- .flex_initial()
+ .flex_none()
.px_1()
.border_1()
.rounded_sm()
diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs
index d0f50c00336eb971621e2da7bbaf53cf09569caa..405948ea06c7e86fcb3dec217186596bdaaf0aeb 100644
--- a/crates/ui/src/components/label/label.rs
+++ b/crates/ui/src/components/label/label.rs
@@ -73,6 +73,34 @@ impl Label {
gpui::margin_style_methods!({
visibility: pub
});
+
+ pub fn flex_1(mut self) -> Self {
+ self.style().flex_grow = Some(1.);
+ self.style().flex_shrink = Some(1.);
+ self.style().flex_basis = Some(gpui::relative(0.).into());
+ self
+ }
+
+ pub fn flex_none(mut self) -> Self {
+ self.style().flex_grow = Some(0.);
+ self.style().flex_shrink = Some(0.);
+ self
+ }
+
+ pub fn flex_grow(mut self) -> Self {
+ self.style().flex_grow = Some(1.);
+ self
+ }
+
+ pub fn flex_shrink(mut self) -> Self {
+ self.style().flex_shrink = Some(1.);
+ self
+ }
+
+ pub fn flex_shrink_0(mut self) -> Self {
+ self.style().flex_shrink = Some(0.);
+ self
+ }
}
impl LabelCommon for Label {
diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs
index 01e88e1fe666fa2038b05af055a0e02b195e9bac..d707df82f4d19b0a3f519e9d6ac9ccdb22965e27 100644
--- a/crates/ui/src/components/list/list_item.rs
+++ b/crates/ui/src/components/list/list_item.rs
@@ -48,6 +48,7 @@ pub struct ListItem {
rounded: bool,
overflow_x: bool,
focused: Option,
+ docked_right: bool,
}
impl ListItem {
@@ -78,6 +79,7 @@ impl ListItem {
rounded: false,
overflow_x: false,
focused: None,
+ docked_right: false,
}
}
@@ -194,6 +196,11 @@ impl ListItem {
self.focused = Some(focused);
self
}
+
+ pub fn docked_right(mut self, docked_right: bool) -> Self {
+ self.docked_right = docked_right;
+ self
+ }
}
impl Disableable for ListItem {
@@ -247,6 +254,7 @@ impl RenderOnce for ListItem {
this.when_some(self.focused, |this, focused| {
if focused {
this.border_1()
+ .when(self.docked_right, |this| this.border_r_2())
.border_color(cx.theme().colors().border_focused)
} else {
this.border_1()
diff --git a/crates/web_search_providers/src/cloud.rs b/crates/web_search_providers/src/cloud.rs
index c8bc89953f2b2d3ec62bac07e80f2737522824f7..51be6c9ddff01a956eebabe3e44166ae15de4515 100644
--- a/crates/web_search_providers/src/cloud.rs
+++ b/crates/web_search_providers/src/cloud.rs
@@ -5,9 +5,9 @@ use client::{Client, UserStore};
use cloud_api_types::OrganizationId;
use cloud_llm_client::{WebSearchBody, WebSearchResponse};
use futures::AsyncReadExt as _;
-use gpui::{App, AppContext, Context, Entity, Subscription, Task};
+use gpui::{App, AppContext, Context, Entity, Task};
use http_client::{HttpClient, Method};
-use language_model::{LlmApiToken, NeedsLlmTokenRefresh, RefreshLlmTokenListener};
+use language_model::{LlmApiToken, NeedsLlmTokenRefresh};
use web_search::{WebSearchProvider, WebSearchProviderId};
pub struct CloudWebSearchProvider {
@@ -26,34 +26,16 @@ pub struct State {
client: Arc,
user_store: Entity,
llm_api_token: LlmApiToken,
- _llm_token_subscription: Subscription,
}
impl State {
pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self {
- let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx);
+ let llm_api_token = LlmApiToken::global(cx);
Self {
client,
user_store,
- llm_api_token: LlmApiToken::default(),
- _llm_token_subscription: cx.subscribe(
- &refresh_llm_token_listener,
- |this, _, _event, cx| {
- let client = this.client.clone();
- let llm_api_token = this.llm_api_token.clone();
- let organization_id = this
- .user_store
- .read(cx)
- .current_organization()
- .map(|o| o.id.clone());
- cx.spawn(async move |_this, _cx| {
- llm_api_token.refresh(&client, organization_id).await?;
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
- },
- ),
+ llm_api_token,
}
}
}
diff --git a/nix/build.nix b/nix/build.nix
index d96a7e51ca08d23572b01f0c387d6ef9e4f2dd70..a5ced61bbbfd145c1e3f9fc9909ae69779ba133a 100644
--- a/nix/build.nix
+++ b/nix/build.nix
@@ -224,7 +224,7 @@ let
};
ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled.";
RELEASE_VERSION = version;
- ZED_COMMIT_SHA = commitSha;
+ ZED_COMMIT_SHA = lib.optionalString (commitSha != null) "${commitSha}";
LK_CUSTOM_WEBRTC = pkgs.callPackage ./livekit-libwebrtc/package.nix { };
PROTOC = "${protobuf}/bin/protoc";
| |