diff --git a/Cargo.lock b/Cargo.lock
index a0a9f798c921c8541ae2e4aaa5ed3bb957d9fb71..2521700c1b6c0453bc14e740a2a32c60a35d34c2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1253,6 +1253,7 @@ dependencies = [
"fs",
"gpui",
"markdown_preview",
+ "notifications",
"release_channel",
"semver",
"serde",
@@ -6137,7 +6138,23 @@ dependencies = [
name = "feature_flags"
version = "0.1.0"
dependencies = [
+ "collections",
+ "feature_flags_macros",
+ "fs",
"gpui",
+ "inventory",
+ "schemars",
+ "serde_json",
+ "settings",
+]
+
+[[package]]
+name = "feature_flags_macros"
+version = "0.1.0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
@@ -9095,6 +9112,7 @@ dependencies = [
"collections",
"dap",
"extension",
+ "feature_flags",
"gpui",
"language",
"parking_lot",
@@ -16043,6 +16061,7 @@ dependencies = [
"db",
"editor",
"extension",
+ "feature_flags",
"fs",
"git",
"gpui",
diff --git a/Cargo.toml b/Cargo.toml
index 75cd2f54b733b7ca7894743223f854d8c26a71c3..73487fc162c039e21ad74d7de33c5c8e023aea58 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -73,6 +73,7 @@ members = [
"crates/extension_host",
"crates/extensions_ui",
"crates/feature_flags",
+ "crates/feature_flags_macros",
"crates/feedback",
"crates/file_finder",
"crates/file_icons",
@@ -325,6 +326,7 @@ extension = { path = "crates/extension" }
extension_host = { path = "crates/extension_host" }
extensions_ui = { path = "crates/extensions_ui" }
feature_flags = { path = "crates/feature_flags" }
+feature_flags_macros = { path = "crates/feature_flags_macros" }
feedback = { path = "crates/feedback" }
file_finder = { path = "crates/file_finder" }
file_icons = { path = "crates/file_icons" }
@@ -891,6 +893,7 @@ debug = true
# proc-macros start
gpui_macros = { opt-level = 3 }
derive_refineable = { opt-level = 3 }
+feature_flags_macros = { opt-level = 3 }
settings_macros = { opt-level = 3 }
sqlez_macros = { opt-level = 3, codegen-units = 1 }
ui_macros = { opt-level = 3 }
diff --git a/assets/icons/clock.svg b/assets/icons/clock.svg
new file mode 100644
index 0000000000000000000000000000000000000000..fb8c6f851fff966732d287e296d29cf6383942b3
--- /dev/null
+++ b/assets/icons/clock.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/knockouts/archive_bg.svg b/assets/icons/knockouts/archive_bg.svg
deleted file mode 100644
index 1954d14b1ee16adf605e2cfe31309838d2448f7a..0000000000000000000000000000000000000000
--- a/assets/icons/knockouts/archive_bg.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
diff --git a/assets/icons/knockouts/archive_fg.svg b/assets/icons/knockouts/archive_fg.svg
deleted file mode 100644
index 74d1238c5399105ba83046c63f4505b0d1fec877..0000000000000000000000000000000000000000
--- a/assets/icons/knockouts/archive_fg.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/assets/icons/thread_import.svg b/assets/icons/thread_import.svg
deleted file mode 100644
index a56b5a7cccc09c5795bfadff06f06d15833232f3..0000000000000000000000000000000000000000
--- a/assets/icons/thread_import.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index 15a4a6f64d5e1a12686d43ddc01dee14df80a7f4..41618016de95757615ea6d528aff11906620c0c5 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -384,6 +384,12 @@
"backspace": "agent::RemoveSelectedThread",
},
},
+ {
+ "context": "ThreadsArchiveView",
+ "bindings": {
+ "shift-backspace": "agent::ArchiveSelectedThread",
+ },
+ },
{
"context": "RulesLibrary",
"bindings": {
@@ -720,8 +726,9 @@
"right": "menu::SelectChild",
"enter": "menu::Confirm",
"ctrl-f": "agents_sidebar::FocusSidebarFilter",
- "ctrl-g": "agents_sidebar::ViewAllThreads",
- "shift-backspace": "agent::RemoveSelectedThread",
+ "ctrl-g": "agents_sidebar::ToggleThreadHistory",
+ "shift-backspace": "agent::ArchiveSelectedThread",
+ "ctrl-backspace": "agent::RemoveSelectedThread",
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
},
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index 4121b376b5a142e298bc655ac8c925507aaeae6e..fa255bfe6a21ce917b64fa8e4a056d1b53e6936c 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -431,6 +431,12 @@
"shift-backspace": "agent::RemoveSelectedThread",
},
},
+ {
+ "context": "ThreadsArchiveView",
+ "bindings": {
+ "backspace": "agent::ArchiveSelectedThread",
+ },
+ },
{
"context": "RulesLibrary",
"use_key_equivalents": true,
@@ -783,8 +789,9 @@
"right": "menu::SelectChild",
"enter": "menu::Confirm",
"cmd-f": "agents_sidebar::FocusSidebarFilter",
- "cmd-g": "agents_sidebar::ViewAllThreads",
- "shift-backspace": "agent::RemoveSelectedThread",
+ "cmd-g": "agents_sidebar::ToggleThreadHistory",
+ "shift-backspace": "agent::ArchiveSelectedThread",
+ "cmd-shift-backspace": "agent::RemoveSelectedThread",
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
},
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 2098f18ff0f2e264b6891f412a29540972629a92..39674ec90db9489b7430c08969fc3c424d483ff3 100644
--- a/assets/keymaps/default-windows.json
+++ b/assets/keymaps/default-windows.json
@@ -386,6 +386,13 @@
"backspace": "agent::RemoveSelectedThread",
},
},
+ {
+ "context": "ThreadsArchiveView",
+ "use_key_equivalents": true,
+ "bindings": {
+ "shift-backspace": "agent::ArchiveSelectedThread",
+ },
+ },
{
"context": "RulesLibrary",
"use_key_equivalents": true,
@@ -720,8 +727,9 @@
"right": "menu::SelectChild",
"enter": "menu::Confirm",
"ctrl-f": "agents_sidebar::FocusSidebarFilter",
- "ctrl-g": "agents_sidebar::ViewAllThreads",
- "shift-backspace": "agent::RemoveSelectedThread",
+ "ctrl-g": "agents_sidebar::ToggleThreadHistory",
+ "shift-backspace": "agent::ArchiveSelectedThread",
+ "ctrl-backspace": "agent::RemoveSelectedThread",
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
},
diff --git a/assets/settings/default.json b/assets/settings/default.json
index 584c4c4d49d573be8ca600edde638c428bace3e6..c25919678bc3ab4b54aa8ac29ed17cc5650b77e8 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -990,7 +990,11 @@
"default_width": 640,
// Default height when the agent panel is docked to the bottom.
"default_height": 320,
- // Maximum content width when the agent panel is wider than this value.
+ // Whether to limit the content width in the agent panel. When enabled,
+ // content will be constrained to `max_content_width` and centered when
+ // the panel is wider, for optimal readability.
+ "limit_content_width": true,
+ // Maximum content width in pixels when limit_content_width is enabled.
// Content will be centered within the panel.
"max_content_width": 850,
// The default model to use when creating new threads.
diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs
index fcb901347a12798aa8e2e40942f88b47beee011d..160172c5c49d3af5548ccd76af1e441662fadfbb 100644
--- a/crates/agent/src/agent.rs
+++ b/crates/agent/src/agent.rs
@@ -47,7 +47,7 @@ use prompt_store::{
WorktreeContext,
};
use serde::{Deserialize, Serialize};
-use settings::{LanguageModelSelection, update_settings_file};
+use settings::{LanguageModelSelection, Settings as _, update_settings_file};
use std::any::Any;
use std::path::PathBuf;
use std::rc::Rc;
@@ -201,7 +201,7 @@ impl LanguageModels {
.map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
.collect::>();
- cx.background_spawn(async move {
+ cx.spawn(async move |cx| {
for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
if let Err(err) = authenticate_task.await {
match err {
@@ -244,6 +244,8 @@ impl LanguageModels {
}
}
}
+
+ cx.update(language_models::update_environment_fallback_model);
})
}
}
@@ -365,7 +367,7 @@ impl NativeAgent {
});
let registry = LanguageModelRegistry::read_global(cx);
- let summarization_model = registry.thread_summary_model().map(|c| c.model);
+ let summarization_model = registry.thread_summary_model(cx).map(|c| c.model);
let weak = cx.weak_entity();
let weak_thread = thread_handle.downgrade();
@@ -749,13 +751,14 @@ impl NativeAgent {
let registry = LanguageModelRegistry::read_global(cx);
let default_model = registry.default_model().map(|m| m.model);
- let summarization_model = registry.thread_summary_model().map(|m| m.model);
+ let summarization_model = registry.thread_summary_model(cx).map(|m| m.model);
for session in self.sessions.values_mut() {
session.thread.update(cx, |thread, cx| {
- if thread.model().is_none()
- && let Some(model) = default_model.clone()
- {
+ let should_update_model = thread.model().is_none()
+ || (thread.is_empty()
+ && matches!(event, language_model::Event::DefaultModelChanged));
+ if should_update_model && let Some(model) = default_model.clone() {
thread.set_model(model, cx);
cx.notify();
}
@@ -910,7 +913,7 @@ impl NativeAgent {
.get(&project_id)
.context("project state not found")?;
let summarization_model = LanguageModelRegistry::read_global(cx)
- .thread_summary_model()
+ .thread_summary_model(cx)
.map(|c| c.model);
Ok(cx.new(|cx| {
@@ -1420,16 +1423,29 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
return Task::ready(Err(anyhow!("Invalid model ID {}", model_id)));
};
- // We want to reset the effort level when switching models, as the currently-selected effort level may
- // not be compatible.
- let effort = model
- .default_effort_level()
- .map(|effort_level| effort_level.value.to_string());
+ let favorite = agent_settings::AgentSettings::get_global(cx)
+ .favorite_models
+ .iter()
+ .find(|favorite| {
+ favorite.provider.0 == model.provider_id().0.as_ref()
+ && favorite.model == model.id().0.as_ref()
+ })
+ .cloned();
+
+ let LanguageModelSelection {
+ enable_thinking,
+ effort,
+ speed,
+ ..
+ } = agent_settings::language_model_to_selection(&model, favorite.as_ref());
thread.update(cx, |thread, cx| {
thread.set_model(model.clone(), cx);
thread.set_thinking_effort(effort.clone(), cx);
- thread.set_thinking_enabled(model.supports_thinking(), cx);
+ thread.set_thinking_enabled(enable_thinking, cx);
+ if let Some(speed) = speed {
+ thread.set_speed(speed, cx);
+ }
});
update_settings_file(
diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs
index 305c4f51952b3bf7e2771a8372a5975cac9e9385..e11e823caee7ad3b8968372f1b089f053a5fe721 100644
--- a/crates/agent/src/native_agent_server.rs
+++ b/crates/agent/src/native_agent_server.rs
@@ -2,11 +2,12 @@ use std::{any::Any, rc::Rc, sync::Arc};
use agent_client_protocol as acp;
use agent_servers::{AgentServer, AgentServerDelegate};
-use agent_settings::AgentSettings;
+use agent_settings::{AgentSettings, language_model_to_selection};
use anyhow::Result;
use collections::HashSet;
use fs::Fs;
use gpui::{App, Entity, Task};
+use language_model::{LanguageModelId, LanguageModelProviderId, LanguageModelRegistry};
use project::{AgentId, Project};
use prompt_store::PromptStore;
use settings::{LanguageModelSelection, Settings as _, update_settings_file};
@@ -76,7 +77,7 @@ impl AgentServer for NativeAgentServer {
fs: Arc,
cx: &App,
) {
- let selection = model_id_to_selection(&model_id);
+ let selection = model_id_to_selection(&model_id, cx);
update_settings_file(fs, cx, move |settings, _| {
let agent = settings.agent.get_or_insert_default();
if should_be_favorite {
@@ -89,16 +90,41 @@ impl AgentServer for NativeAgentServer {
}
/// Convert a ModelId (e.g. "anthropic/claude-3-5-sonnet") to a LanguageModelSelection.
-fn model_id_to_selection(model_id: &acp::ModelId) -> LanguageModelSelection {
+fn model_id_to_selection(model_id: &acp::ModelId, cx: &App) -> LanguageModelSelection {
let id = model_id.0.as_ref();
let (provider, model) = id.split_once('/').unwrap_or(("", id));
- LanguageModelSelection {
- provider: provider.to_owned().into(),
- model: model.to_owned(),
- enable_thinking: false,
- effort: None,
- speed: None,
- }
+
+ let provider_id = LanguageModelProviderId(provider.to_string().into());
+ let model_id_typed = LanguageModelId(model.to_string().into());
+ let resolved = LanguageModelRegistry::global(cx)
+ .read(cx)
+ .provider(&provider_id)
+ .and_then(|p| {
+ p.provided_models(cx)
+ .into_iter()
+ .find(|m| m.id() == model_id_typed)
+ });
+
+ let Some(resolved) = resolved else {
+ return LanguageModelSelection {
+ provider: provider.to_owned().into(),
+ model: model.to_owned(),
+ enable_thinking: false,
+ effort: None,
+ speed: None,
+ };
+ };
+
+ let current_user_selection = AgentSettings::get_global(cx)
+ .default_model
+ .as_ref()
+ .filter(|selection| {
+ selection.provider.0 == resolved.provider_id().0.as_ref()
+ && selection.model == resolved.id().0.as_ref()
+ })
+ .cloned();
+
+ language_model_to_selection(&resolved, current_user_selection.as_ref())
}
#[cfg(test)]
diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs
index ff9e735b6c4181588ed5cddbd6dada7fbae5f18f..65cbcfb2c609cb146537d784dd04f7749d71fe90 100644
--- a/crates/agent/src/tool_permissions.rs
+++ b/crates/agent/src/tool_permissions.rs
@@ -574,7 +574,7 @@ mod tests {
flexible: true,
default_width: px(300.),
default_height: px(600.),
- max_content_width: px(850.),
+ max_content_width: Some(px(850.)),
default_model: None,
inline_assistant_model: None,
inline_assistant_use_streaming_tools: false,
diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs
index a8b21fcbd849962a4a1d098eaa681b0f56016bc6..e3a52555e70dba1a825889209f2ec7b9c1f689e3 100644
--- a/crates/agent_settings/src/agent_settings.rs
+++ b/crates/agent_settings/src/agent_settings.rs
@@ -142,7 +142,7 @@ pub struct AgentSettings {
pub sidebar_side: SidebarDockPosition,
pub default_width: Pixels,
pub default_height: Pixels,
- pub max_content_width: Pixels,
+ pub max_content_width: Option,
pub default_model: Option,
pub inline_assistant_model: Option,
pub inline_assistant_use_streaming_tools: bool,
@@ -210,7 +210,48 @@ impl AgentSettings {
.map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model)))
.collect()
}
+}
+
+pub fn language_model_to_selection(
+ model: &Arc,
+ override_selection: Option<&LanguageModelSelection>,
+) -> LanguageModelSelection {
+ let provider = model.provider_id().0.to_string().into();
+ let model_name = model.id().0.to_string();
+ match override_selection {
+ Some(current) => LanguageModelSelection {
+ provider,
+ model: model_name,
+ enable_thinking: current.enable_thinking && model.supports_thinking(),
+ effort: current
+ .effort
+ .clone()
+ .filter(|value| {
+ model
+ .supported_effort_levels()
+ .iter()
+ .any(|level| level.value.as_ref() == value.as_str())
+ })
+ .or_else(|| {
+ model
+ .default_effort_level()
+ .map(|effort| effort.value.to_string())
+ }),
+ speed: current.speed.filter(|_| model.supports_fast_mode()),
+ },
+ None => LanguageModelSelection {
+ provider,
+ model: model_name,
+ enable_thinking: model.supports_thinking(),
+ effort: model
+ .default_effort_level()
+ .map(|effort| effort.value.to_string()),
+ speed: None,
+ },
+ }
+}
+impl AgentSettings {
pub fn get_layout(cx: &App) -> WindowLayout {
let store = cx.global::();
let merged = store.merged_settings();
@@ -593,7 +634,11 @@ impl Settings for AgentSettings {
sidebar_side: agent.sidebar_side.unwrap(),
default_width: px(agent.default_width.unwrap()),
default_height: px(agent.default_height.unwrap()),
- max_content_width: px(agent.max_content_width.unwrap()),
+ max_content_width: if agent.limit_content_width.unwrap() {
+ Some(px(agent.max_content_width.unwrap()))
+ } else {
+ None
+ },
flexible: agent.flexible.unwrap(),
default_model: Some(agent.default_model.unwrap()),
inline_assistant_model: agent.inline_assistant_model,
diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs
index fda3cb9907b2f02cce29ff0ae8c4762e6efa625a..13ec53b25c50b5865c0070daee76d7eadde10c7b 100644
--- a/crates/agent_ui/src/agent_configuration.rs
+++ b/crates/agent_ui/src/agent_configuration.rs
@@ -26,7 +26,7 @@ use language_model::{
ZED_CLOUD_PROVIDER_ID,
};
use language_models::AllLanguageModelSettings;
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
use project::{
agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource},
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
@@ -1330,40 +1330,44 @@ fn show_unable_to_uninstall_extension_with_context_server(
move |this, _cx| {
let workspace_handle = workspace_handle.clone();
- this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
- .dismiss_button(true)
- .action("Uninstall", move |_, _cx| {
- if let Some((extension_id, _)) =
- resolve_extension_for_context_server(&context_server_id, _cx)
- {
- ExtensionStore::global(_cx).update(_cx, |store, cx| {
- store
- .uninstall_extension(extension_id, cx)
- .detach_and_log_err(cx);
- });
+ this.icon(
+ Icon::new(IconName::Warning)
+ .size(IconSize::Small)
+ .color(Color::Warning),
+ )
+ .dismiss_button(true)
+ .action("Uninstall", move |_, _cx| {
+ if let Some((extension_id, _)) =
+ resolve_extension_for_context_server(&context_server_id, _cx)
+ {
+ ExtensionStore::global(_cx).update(_cx, |store, cx| {
+ store
+ .uninstall_extension(extension_id, cx)
+ .detach_and_log_err(cx);
+ });
- workspace_handle
- .update(_cx, |workspace, cx| {
- let fs = workspace.app_state().fs.clone();
- cx.spawn({
- let context_server_id = context_server_id.clone();
- async move |_workspace_handle, cx| {
- cx.update(|cx| {
- update_settings_file(fs, cx, move |settings, _| {
- settings
- .project
- .context_servers
- .remove(&context_server_id.0);
- });
+ workspace_handle
+ .update(_cx, |workspace, cx| {
+ let fs = workspace.app_state().fs.clone();
+ cx.spawn({
+ let context_server_id = context_server_id.clone();
+ async move |_workspace_handle, cx| {
+ cx.update(|cx| {
+ update_settings_file(fs, cx, move |settings, _| {
+ settings
+ .project
+ .context_servers
+ .remove(&context_server_id.0);
});
- anyhow::Ok(())
- }
- })
- .detach_and_log_err(cx);
+ });
+ anyhow::Ok(())
+ }
})
- .log_err();
- }
- })
+ .detach_and_log_err(cx);
+ })
+ .log_err();
+ }
+ })
},
);
diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs
index 9c44288e1cd23cd3bb0d6876f086c3f0e89dc4c7..465d31b416e9e85a4fc94a0b7f507c7560bf422a 100644
--- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs
+++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs
@@ -9,7 +9,7 @@ use gpui::{
};
use language::{Language, LanguageRegistry};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
use parking_lot::Mutex;
use project::{
context_server_store::{
@@ -631,8 +631,12 @@ impl ConfigureContextServerModal {
format!("{} configured successfully.", id.0),
cx,
|this, _cx| {
- this.icon(ToastIcon::new(IconName::ToolHammer).color(Color::Muted))
- .action("Dismiss", |_, _| {})
+ this.icon(
+ Icon::new(IconName::ToolHammer)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .action("Dismiss", |_, _| {})
},
);
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 88375dc7d6c4c4f4bf291b2a8d5d7d8dae602e41..9b79b2c57c685f37566ad5f21e0afd45c127b6d1 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -35,8 +35,8 @@ use crate::{
AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CreateWorktree,
Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget,
OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
- SwitchWorktree, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
- ToggleWorktreeSelector,
+ ShowAllSidebarThreadMetadata, ShowThreadMetadata, SwitchWorktree, ToggleNavigationMenu,
+ ToggleNewThreadMenu, ToggleOptionsMenu, ToggleWorktreeSelector,
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
conversation_view::{AcpThreadViewEvent, ThreadView},
thread_worktree_picker::ThreadWorktreePicker,
@@ -49,13 +49,14 @@ use crate::{
use crate::{ExpandMessageEditor, ThreadHistoryView};
use crate::{ManageProfiles, ThreadHistoryViewEvent};
use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
-use agent_settings::{AgentSettings, WindowLayout};
+use agent_settings::AgentSettings;
use ai_onboarding::AgentPanelOnboarding;
use anyhow::{Context as _, Result, anyhow};
+use chrono::{DateTime, Utc};
use client::UserStore;
use cloud_api_types::Plan;
use collections::HashMap;
-use editor::Editor;
+use editor::{Editor, MultiBuffer};
use extension::ExtensionEvents;
use extension_host::ExtensionStore;
use fs::Fs;
@@ -325,6 +326,24 @@ pub fn init(cx: &mut App) {
});
}
})
+ .register_action(|workspace, _: &ShowThreadMetadata, window, cx| {
+ if let Some(panel) = workspace.panel::(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.show_thread_metadata(&ShowThreadMetadata, window, cx);
+ });
+ }
+ })
+ .register_action(|workspace, _: &ShowAllSidebarThreadMetadata, window, cx| {
+ if let Some(panel) = workspace.panel::(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.show_all_sidebar_thread_metadata(
+ &ShowAllSidebarThreadMetadata,
+ window,
+ cx,
+ );
+ });
+ }
+ })
.register_action(|workspace, action: &ReviewBranchDiff, window, cx| {
let Some(panel) = workspace.panel::(cx) else {
return;
@@ -624,6 +643,46 @@ fn build_conflicted_files_resolution_prompt(
content
}
+fn format_timestamp_human(dt: &DateTime) -> String {
+ let now = Utc::now();
+ let duration = now.signed_duration_since(*dt);
+
+ let relative = if duration.num_seconds() < 0 {
+ "in the future".to_string()
+ } else if duration.num_seconds() < 60 {
+ let seconds = duration.num_seconds();
+ format!("{seconds} seconds ago")
+ } else if duration.num_minutes() < 60 {
+ let minutes = duration.num_minutes();
+ format!("{minutes} minutes ago")
+ } else if duration.num_hours() < 24 {
+ let hours = duration.num_hours();
+ format!("{hours} hours ago")
+ } else {
+ let days = duration.num_days();
+ format!("{days} days ago")
+ };
+
+ format!("{} ({})", dt.to_rfc3339(), relative)
+}
+
+/// Used for `dev: show thread metadata` action
+fn thread_metadata_to_debug_json(
+ metadata: &crate::thread_metadata_store::ThreadMetadata,
+) -> serde_json::Value {
+ serde_json::json!({
+ "thread_id": metadata.thread_id,
+ "session_id": metadata.session_id.as_ref().map(|s| s.0.to_string()),
+ "agent_id": metadata.agent_id.0.to_string(),
+ "title": metadata.title.as_ref().map(|t| t.to_string()),
+ "updated_at": format_timestamp_human(&metadata.updated_at),
+ "created_at": metadata.created_at.as_ref().map(format_timestamp_human),
+ "interacted_at": metadata.interacted_at.as_ref().map(format_timestamp_human),
+ "worktree_paths": format!("{:?}", metadata.worktree_paths),
+ "archived": metadata.archived,
+ })
+}
+
pub(crate) struct AgentThread {
conversation_view: Entity,
}
@@ -760,8 +819,6 @@ pub struct AgentPanel {
pending_serialization: Option>>,
new_user_onboarding: Entity,
new_user_onboarding_upsell_dismissed: AtomicBool,
- agent_layout_onboarding: Entity,
- agent_layout_onboarding_dismissed: AtomicBool,
selected_agent: Agent,
worktree_creation_status: Option<(EntityId, WorktreeCreationStatus)>,
_thread_view_subscription: Option,
@@ -1064,46 +1121,6 @@ impl AgentPanel {
)
});
- let weak_panel = cx.entity().downgrade();
-
- let layout = AgentSettings::get_layout(cx);
- let is_agent_layout = matches!(layout, WindowLayout::Agent(_));
-
- let agent_layout_onboarding = cx.new(|_cx| ai_onboarding::AgentLayoutOnboarding {
- use_agent_layout: Arc::new({
- let fs = fs.clone();
- let weak_panel = weak_panel.clone();
- move |_window, cx| {
- let _ = AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
- weak_panel
- .update(cx, |panel, cx| {
- panel.dismiss_agent_layout_onboarding(cx);
- })
- .ok();
- }
- }),
- revert_to_editor_layout: Arc::new({
- let fs = fs.clone();
- let weak_panel = weak_panel.clone();
- move |_window, cx| {
- let _ = AgentSettings::set_layout(WindowLayout::Editor(None), fs.clone(), cx);
- weak_panel
- .update(cx, |panel, cx| {
- panel.dismiss_agent_layout_onboarding(cx);
- })
- .ok();
- }
- }),
- dismissed: Arc::new(move |_window, cx| {
- weak_panel
- .update(cx, |panel, cx| {
- panel.dismiss_agent_layout_onboarding(cx);
- })
- .ok();
- }),
- is_agent_layout,
- });
-
// Subscribe to extension events to sync agent servers when extensions change
let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
{
@@ -1168,7 +1185,6 @@ impl AgentPanel {
zoomed: false,
pending_serialization: None,
new_user_onboarding: onboarding,
- agent_layout_onboarding,
thread_store,
selected_agent: Agent::default(),
worktree_creation_status: None,
@@ -1177,9 +1193,6 @@ impl AgentPanel {
_worktree_creation_task: None,
show_trust_workspace_message: false,
new_user_onboarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
- agent_layout_onboarding_dismissed: AtomicBool::new(AgentLayoutOnboarding::dismissed(
- cx,
- )),
_base_view_observation: None,
_draft_editor_observation: None,
};
@@ -2025,6 +2038,108 @@ impl AgentPanel {
.detach_and_log_err(cx);
}
+ fn show_thread_metadata(
+ &mut self,
+ _: &ShowThreadMetadata,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let Some(thread_id) = self.active_thread_id(cx) else {
+ Self::show_deferred_toast(&self.workspace, "No active thread", cx);
+ return;
+ };
+
+ let Some(store) = ThreadMetadataStore::try_global(cx) else {
+ Self::show_deferred_toast(&self.workspace, "Thread metadata store not available", cx);
+ return;
+ };
+
+ let Some(metadata) = store.read(cx).entry(thread_id).cloned() else {
+ Self::show_deferred_toast(&self.workspace, "No metadata found for active thread", cx);
+ return;
+ };
+
+ let json = thread_metadata_to_debug_json(&metadata);
+ let text = serde_json::to_string_pretty(&json).unwrap_or_default();
+ let title = format!("Thread Metadata: {}", metadata.display_title());
+
+ self.open_json_buffer(title, text, window, cx);
+ }
+
+ fn show_all_sidebar_thread_metadata(
+ &mut self,
+ _: &ShowAllSidebarThreadMetadata,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let Some(store) = ThreadMetadataStore::try_global(cx) else {
+ Self::show_deferred_toast(&self.workspace, "Thread metadata store not available", cx);
+ return;
+ };
+
+ let entries: Vec = store
+ .read(cx)
+ .entries()
+ .filter(|t| !t.archived)
+ .map(thread_metadata_to_debug_json)
+ .collect();
+
+ let json = serde_json::Value::Array(entries);
+ let text = serde_json::to_string_pretty(&json).unwrap_or_default();
+
+ self.open_json_buffer("All Sidebar Thread Metadata".to_string(), text, window, cx);
+ }
+
+ fn open_json_buffer(
+ &self,
+ title: String,
+ text: String,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let json_language = self.language_registry.language_for_name("JSON");
+ let project = self.project.clone();
+ let workspace = self.workspace.clone();
+
+ window
+ .spawn(cx, async move |cx| {
+ let json_language = json_language.await.ok();
+
+ let buffer = project
+ .update(cx, |project, cx| {
+ project.create_buffer(json_language, false, cx)
+ })
+ .await?;
+
+ buffer.update(cx, |buffer, cx| {
+ buffer.set_text(text, cx);
+ buffer.set_capability(language::Capability::ReadWrite, cx);
+ });
+
+ workspace.update_in(cx, |workspace, window, cx| {
+ let buffer =
+ cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.clone()));
+
+ workspace.add_item_to_active_pane(
+ Box::new(cx.new(|cx| {
+ let mut editor =
+ Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
+ editor.set_breadcrumb_header(title);
+ editor.disable_mouse_wheel_zoom();
+ editor
+ })),
+ None,
+ true,
+ window,
+ cx,
+ );
+ })?;
+
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
fn handle_agent_configuration_event(
&mut self,
_entity: &Entity,
@@ -2445,7 +2560,7 @@ impl AgentPanel {
&tv,
window,
|this, _view, event: &AcpThreadViewEvent, _window, cx| match event {
- AcpThreadViewEvent::MessageSentOrQueued => {
+ AcpThreadViewEvent::Interacted => {
let Some(thread_id) = this.active_thread_id(cx) else {
return;
};
@@ -2457,7 +2572,7 @@ impl AgentPanel {
this._draft_editor_observation = None;
}
this.retained_threads.remove(&thread_id);
- cx.emit(AgentPanelEvent::MessageSentOrQueued { thread_id });
+ cx.emit(AgentPanelEvent::ThreadInteracted { thread_id });
}
},
)
@@ -3276,26 +3391,6 @@ impl AgentPanel {
return;
}
- let (worktree_receivers, worktree_directory_setting) =
- if matches!(args, WorktreeCreationArgs::New { .. }) {
- (
- Some(
- git_repos
- .iter()
- .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
- .collect::>(),
- ),
- Some(
- ProjectSettings::get_global(cx)
- .git
- .worktree_directory
- .clone(),
- ),
- )
- } else {
- (None, None)
- };
-
let remote_connection_options = self.project.read(cx).remote_connection_options(cx);
if remote_connection_options.is_some() {
@@ -3332,10 +3427,18 @@ impl AgentPanel {
worktree_name,
branch_target,
} => {
- let worktree_receivers = worktree_receivers
- .expect("worktree receivers must be prepared for new worktree creation");
- let worktree_directory_setting = worktree_directory_setting
- .expect("worktree directory must be prepared for new worktree creation");
+ let worktree_receivers: Vec<_> = this.update_in(cx, |_this, _window, cx| {
+ git_repos
+ .iter()
+ .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
+ .collect()
+ })?;
+ let worktree_directory_setting = this.update_in(cx, |_this, _window, cx| {
+ ProjectSettings::get_global(cx)
+ .git
+ .worktree_directory
+ .clone()
+ })?;
let mut existing_worktree_names = Vec::new();
let mut existing_worktree_paths = HashSet::default();
@@ -3740,7 +3843,7 @@ pub enum AgentPanelEvent {
ActiveViewChanged,
ThreadFocused,
RetainedThreadChanged,
- MessageSentOrQueued { thread_id: ThreadId },
+ ThreadInteracted { thread_id: ThreadId },
}
impl EventEmitter for AgentPanel {}
@@ -3989,12 +4092,14 @@ impl AgentPanel {
BaseView::AgentThread { conversation_view } => Some(conversation_view.clone()),
_ => None,
};
- let thread_with_messages = match &self.base_view {
- BaseView::AgentThread { conversation_view } => {
- conversation_view.read(cx).has_user_submitted_prompt(cx)
- }
- _ => false,
- };
+
+ let can_regenerate_thread_title =
+ conversation_view.as_ref().is_some_and(|conversation_view| {
+ let conversation_view = conversation_view.read(cx);
+ conversation_view.has_user_submitted_prompt(cx)
+ && conversation_view.as_native_thread(cx).is_some()
+ });
+
let has_auth_methods = match &self.base_view {
BaseView::AgentThread { conversation_view } => {
conversation_view.read(cx).has_auth_methods()
@@ -4025,7 +4130,7 @@ impl AgentPanel {
Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
menu = menu.context(focus_handle.clone());
- if thread_with_messages {
+ if can_regenerate_thread_title {
menu = menu.header("Current Thread");
if let Some(conversation_view) = conversation_view.as_ref() {
@@ -4159,7 +4264,7 @@ impl AgentPanel {
let current_path = &repo.work_directory_abs_path;
return linked_worktree_short_name(main_path, current_path)
- .unwrap_or_else(|| "main".into());
+ .unwrap_or_else(|| "main worktree".into());
}
project
@@ -4527,9 +4632,8 @@ impl AgentPanel {
let base_container = h_flex()
.size_full()
- // TODO: This is only until we remove Agent settings from the panel.
.when(!is_in_history_or_config, |this| {
- this.max_w(max_content_width).mx_auto()
+ this.when_some(max_content_width, |this, max_w| this.max_w(max_w).mx_auto())
})
.flex_none()
.justify_between()
@@ -4737,56 +4841,10 @@ impl AgentPanel {
plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
}
- fn should_render_agent_layout_onboarding(&self, cx: &mut Context) -> bool {
- // We only want to show this for existing users: those who
- // have used the agent panel before the sidebar was introduced.
- // We can infer that state by users having seen the onboarding
- // at one point, but not the agent layout onboarding.
-
- let has_messages = self.active_thread_has_messages(cx);
- let is_dismissed = self
- .agent_layout_onboarding_dismissed
- .load(Ordering::Acquire);
-
- if is_dismissed || has_messages {
- return false;
- }
-
- match &self.base_view {
- BaseView::Uninitialized => false,
- BaseView::AgentThread { .. } => {
- let existing_user = self
- .new_user_onboarding_upsell_dismissed
- .load(Ordering::Acquire);
- existing_user
- }
- }
- }
-
- fn render_agent_layout_onboarding(
- &self,
- _window: &mut Window,
- cx: &mut Context,
- ) -> Option {
- if !self.should_render_agent_layout_onboarding(cx) {
- return None;
- }
-
- Some(div().child(self.agent_layout_onboarding.clone()))
- }
-
- fn dismiss_agent_layout_onboarding(&mut self, cx: &mut Context) {
- self.agent_layout_onboarding_dismissed
- .store(true, Ordering::Release);
- AgentLayoutOnboarding::set_dismissed(true, cx);
- cx.notify();
- }
-
fn dismiss_ai_onboarding(&mut self, cx: &mut Context) {
self.new_user_onboarding_upsell_dismissed
.store(true, Ordering::Release);
OnboardingUpsell::set_dismissed(true, cx);
- self.dismiss_agent_layout_onboarding(cx);
cx.notify();
}
@@ -5048,7 +5106,6 @@ impl Render for AgentPanel {
.child(self.render_toolbar(window, cx))
.children(self.render_workspace_trust_message(cx))
.children(self.render_new_user_onboarding(window, cx))
- .children(self.render_agent_layout_onboarding(window, cx))
.map(|parent| match self.visible_surface() {
VisibleSurface::Uninitialized => parent,
VisibleSurface::AgentThread(conversation_view) => parent
@@ -5139,12 +5196,6 @@ impl Dismissable for OnboardingUpsell {
const KEY: &'static str = "dismissed-trial-upsell";
}
-struct AgentLayoutOnboarding;
-
-impl Dismissable for AgentLayoutOnboarding {
- const KEY: &'static str = "dismissed-agent-layout-onboarding";
-}
-
struct TrialEndUpsell;
impl Dismissable for TrialEndUpsell {
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index b5b6a7dea69962c2604252913216dcfe72bb2e84..f703507de126fceae632bfd562e4639a18ff052a 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -112,6 +112,8 @@ actions!(
OpenHistory,
/// Adds a context server to the configuration.
AddContextServer,
+ /// Archives the currently selected thread.
+ ArchiveSelectedThread,
/// Removes the currently selected thread.
RemoveSelectedThread,
/// Starts a chat conversation with follow-up enabled.
@@ -203,6 +205,16 @@ actions!(
]
);
+actions!(
+ dev,
+ [
+ /// Shows metadata for the currently active thread.
+ ShowThreadMetadata,
+ /// Shows metadata for all threads in the sidebar.
+ ShowAllSidebarThreadMetadata,
+ ]
+);
+
/// Action to authorize a tool call with a specific permission option.
/// This is used by the permission granularity dropdown to authorize tool calls.
#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
@@ -766,7 +778,7 @@ mod tests {
flexible: true,
default_width: px(300.),
default_height: px(600.),
- max_content_width: px(850.),
+ max_content_width: Some(px(850.)),
default_model: None,
inline_assistant_model: None,
inline_assistant_use_streaming_tools: false,
diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs
index 787fe774c3b7864ebe679b7bef1525f8a5e6ec49..bd78440df1e49ce55b3cf311b64141a677f473f4 100644
--- a/crates/agent_ui/src/conversation_view.rs
+++ b/crates/agent_ui/src/conversation_view.rs
@@ -396,18 +396,18 @@ fn affects_thread_metadata(event: &AcpThreadEvent) -> bool {
match event {
AcpThreadEvent::NewEntry
| AcpThreadEvent::TitleUpdated
- | AcpThreadEvent::EntryUpdated(_)
- | AcpThreadEvent::EntriesRemoved(_)
| AcpThreadEvent::ToolAuthorizationRequested(_)
| AcpThreadEvent::ToolAuthorizationReceived(_)
- | AcpThreadEvent::Retry(_)
| AcpThreadEvent::Stopped(_)
| AcpThreadEvent::Error
| AcpThreadEvent::LoadError(_)
| AcpThreadEvent::Refusal
| AcpThreadEvent::WorkingDirectoriesUpdated => true,
// --
- AcpThreadEvent::TokenUsageUpdated
+ AcpThreadEvent::EntryUpdated(_)
+ | AcpThreadEvent::EntriesRemoved(_)
+ | AcpThreadEvent::Retry(_)
+ | AcpThreadEvent::TokenUsageUpdated
| AcpThreadEvent::PromptCapabilitiesUpdated
| AcpThreadEvent::AvailableCommandsUpdated(_)
| AcpThreadEvent::ModeUpdated(_)
diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index 1a2f5d38af2d13cf7efc4829d498fa88dc4a09cd..b6bda7738d5424e842d66c4a625fe64684ce5c14 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -206,7 +206,7 @@ impl RenderOnce for GeneratingSpinnerElement {
}
pub enum AcpThreadViewEvent {
- MessageSentOrQueued,
+ Interacted,
}
impl EventEmitter for ThreadView {}
@@ -954,7 +954,6 @@ impl ThreadView {
let has_queued = self.has_queued_messages();
if is_editor_empty && self.can_fast_track_queue && has_queued {
self.can_fast_track_queue = false;
- cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
self.send_queued_message_at_index(0, true, window, cx);
return;
}
@@ -964,7 +963,7 @@ impl ThreadView {
}
if is_generating {
- cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
+ cx.emit(AcpThreadViewEvent::Interacted);
self.queue_message(message_editor, window, cx);
return;
}
@@ -1006,7 +1005,7 @@ impl ThreadView {
}
}
- cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
+ cx.emit(AcpThreadViewEvent::Interacted);
self.send_impl(message_editor, window, cx)
}
@@ -1209,6 +1208,8 @@ impl ThreadView {
return;
}
+ cx.emit(AcpThreadViewEvent::Interacted);
+
let message_editor = self.message_editor.clone();
if thread.read(cx).status() == ThreadStatus::Idle {
self.send_impl(message_editor, window, cx);
@@ -1371,6 +1372,7 @@ impl ThreadView {
}
let task = thread.update(cx, |thread, cx| thread.retry(cx));
+ cx.emit(AcpThreadViewEvent::Interacted);
self.sync_generating_indicator(cx);
cx.notify();
cx.spawn(async move |this, cx| {
@@ -1430,6 +1432,7 @@ impl ThreadView {
.update(cx, |thread, cx| thread.rewind(user_message_id, cx))
.await?;
this.update_in(cx, |thread, window, cx| {
+ cx.emit(AcpThreadViewEvent::Interacted);
thread.send_impl(message_editor, window, cx);
thread.focus_handle(cx).focus(window, cx);
})?;
@@ -1522,6 +1525,8 @@ impl ThreadView {
return;
};
+ cx.emit(AcpThreadViewEvent::Interacted);
+
self.message_editor.focus_handle(cx).focus(window, cx);
let content = queued.content;
@@ -2285,13 +2290,15 @@ impl ThreadView {
h_flex()
.w_full()
+ .px_2()
.justify_center()
.child(
v_flex()
- .flex_basis(max_content_width)
+ .when_some(max_content_width, |this, max_w| this.flex_basis(max_w))
+ .when(max_content_width.is_none(), |this| this.w_full())
.flex_shrink()
.flex_grow_0()
- .mx_2()
+ .max_w_full()
.bg(self.activity_bar_bg(cx))
.border_1()
.border_b_0()
@@ -2844,7 +2851,7 @@ impl ThreadView {
IconButton::new("dismiss-plan", IconName::Close)
.icon_size(IconSize::XSmall)
.shape(ui::IconButtonShape::Square)
- .tooltip(Tooltip::text("Clear plan"))
+ .tooltip(Tooltip::text("Clear Plan"))
.on_click(cx.listener(|this, _, _, cx| {
this.thread.update(cx, |thread, cx| thread.clear_plan(cx));
cx.stop_propagation();
@@ -2868,51 +2875,64 @@ impl ThreadView {
.max_h_40()
.overflow_y_scroll()
.children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
- let element = h_flex()
- .py_1()
- .px_2()
- .gap_2()
- .justify_between()
- .bg(cx.theme().colors().editor_background)
- .when(index < plan.entries.len() - 1, |parent| {
- parent.border_color(cx.theme().colors().border).border_b_1()
- })
- .child(
- h_flex()
- .id(("plan_entry", index))
- .gap_1p5()
- .max_w_full()
- .overflow_x_scroll()
- .text_xs()
- .text_color(cx.theme().colors().text_muted)
- .child(match entry.status {
- acp::PlanEntryStatus::InProgress => {
- Icon::new(IconName::TodoProgress)
- .size(IconSize::Small)
- .color(Color::Accent)
- .with_rotate_animation(2)
- .into_any_element()
- }
- acp::PlanEntryStatus::Completed => {
- Icon::new(IconName::TodoComplete)
- .size(IconSize::Small)
- .color(Color::Success)
- .into_any_element()
- }
- acp::PlanEntryStatus::Pending | _ => {
- Icon::new(IconName::TodoPending)
- .size(IconSize::Small)
- .color(Color::Muted)
- .into_any_element()
- }
- })
- .child(MarkdownElement::new(
- entry.content.clone(),
- plan_label_markdown_style(&entry.status, window, cx),
- )),
- );
+ let entry_bg = cx.theme().colors().editor_background;
+ let tooltip_text: SharedString = entry.content.read(cx).source().to_string().into();
- Some(element)
+ Some(
+ h_flex()
+ .id(("plan_entry_row", index))
+ .py_1()
+ .px_2()
+ .gap_2()
+ .justify_between()
+ .relative()
+ .bg(entry_bg)
+ .when(index < plan.entries.len() - 1, |parent| {
+ parent.border_color(cx.theme().colors().border).border_b_1()
+ })
+ .overflow_hidden()
+ .child(
+ h_flex()
+ .id(("plan_entry", index))
+ .gap_1p5()
+ .min_w_0()
+ .text_xs()
+ .text_color(cx.theme().colors().text_muted)
+ .child(match entry.status {
+ acp::PlanEntryStatus::InProgress => {
+ Icon::new(IconName::TodoProgress)
+ .size(IconSize::Small)
+ .color(Color::Accent)
+ .with_rotate_animation(2)
+ .into_any_element()
+ }
+ acp::PlanEntryStatus::Completed => {
+ Icon::new(IconName::TodoComplete)
+ .size(IconSize::Small)
+ .color(Color::Success)
+ .into_any_element()
+ }
+ acp::PlanEntryStatus::Pending | _ => {
+ Icon::new(IconName::TodoPending)
+ .size(IconSize::Small)
+ .color(Color::Muted)
+ .into_any_element()
+ }
+ })
+ .child(MarkdownElement::new(
+ entry.content.clone(),
+ plan_label_markdown_style(&entry.status, window, cx),
+ )),
+ )
+ .child(div().absolute().top_0().right_0().h_full().w_8().bg(
+ linear_gradient(
+ 90.,
+ linear_color_stop(entry_bg, 1.),
+ linear_color_stop(entry_bg.opacity(0.), 0.),
+ ),
+ ))
+ .tooltip(Tooltip::text(tooltip_text)),
+ )
}))
.into_any_element()
}
@@ -3181,8 +3201,7 @@ impl ThreadView {
.child(
h_flex()
.size_full()
- .max_w(max_content_width)
- .mx_auto()
+ .when_some(max_content_width, |this, max_w| this.max_w(max_w).mx_auto())
.pl_2()
.pr_1()
.flex_shrink_0()
@@ -3279,7 +3298,8 @@ impl ThreadView {
})
.child(
v_flex()
- .flex_basis(max_content_width)
+ .when_some(max_content_width, |this, max_w| this.flex_basis(max_w))
+ .when(max_content_width.is_none(), |this| this.w_full())
.flex_shrink()
.flex_grow_0()
.when(fills_container, |this| this.h_full())
@@ -3822,12 +3842,22 @@ impl ThreadView {
let enable_thinking = !thread.thinking_enabled();
thread.set_thinking_enabled(enable_thinking, cx);
+ let favorite_key = thread.model().map(|model| {
+ (model.provider_id().0.to_string(), model.id().0.to_string())
+ });
let fs = thread.project().read(cx).fs().clone();
update_settings_file(fs, cx, move |settings, _| {
- if let Some(agent) = settings.agent.as_mut()
- && let Some(default_model) = agent.default_model.as_mut()
- {
- default_model.enable_thinking = enable_thinking;
+ if let Some(agent) = settings.agent.as_mut() {
+ if let Some(default_model) = agent.default_model.as_mut() {
+ default_model.enable_thinking = enable_thinking;
+ }
+ if let Some((provider_id, model_id)) = &favorite_key {
+ agent.update_favorite_model(
+ provider_id,
+ model_id,
+ |favorite| favorite.enable_thinking = enable_thinking,
+ );
+ }
}
});
});
@@ -3958,14 +3988,33 @@ impl ThreadView {
cx,
);
+ let favorite_key = thread.model().map(|model| {
+ (
+ model.provider_id().0.to_string(),
+ model.id().0.to_string(),
+ )
+ });
let fs = thread.project().read(cx).fs().clone();
update_settings_file(fs, cx, move |settings, _| {
- if let Some(agent) = settings.agent.as_mut()
- && let Some(default_model) =
+ if let Some(agent) = settings.agent.as_mut() {
+ if let Some(default_model) =
agent.default_model.as_mut()
- {
- default_model.effort =
- Some(effort.to_string());
+ {
+ default_model.effort =
+ Some(effort.to_string());
+ }
+ if let Some((provider_id, model_id)) =
+ &favorite_key
+ {
+ agent.update_favorite_model(
+ provider_id,
+ model_id,
+ |favorite| {
+ favorite.effort =
+ Some(effort.to_string())
+ },
+ );
+ }
}
});
});
@@ -4467,10 +4516,12 @@ impl ThreadView {
fn render_entries(&mut self, cx: &mut Context) -> List {
let max_content_width = AgentSettings::get_global(cx).max_content_width;
let centered_container = move |content: AnyElement| {
- h_flex()
- .w_full()
- .justify_center()
- .child(div().max_w(max_content_width).w_full().child(content))
+ h_flex().w_full().justify_center().child(
+ div()
+ .when_some(max_content_width, |this, max_w| this.max_w(max_w))
+ .w_full()
+ .child(content),
+ )
};
list(
@@ -7693,6 +7744,7 @@ impl ThreadView {
gpui::ImageFormat::Bmp => "BMP",
gpui::ImageFormat::Tiff => "TIFF",
gpui::ImageFormat::Ico => "ICO",
+ gpui::ImageFormat::Pnm => "PNM",
};
let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes()))
.with_guessed_format()
@@ -8771,7 +8823,7 @@ impl ThreadView {
}
fn render_token_limit_callout(&self, cx: &mut Context) -> Option {
- if self.token_limit_callout_dismissed {
+ if self.token_limit_callout_dismissed || self.as_native_thread(cx).is_none() {
return None;
}
@@ -8858,12 +8910,20 @@ impl ThreadView {
.unwrap_or(Speed::Fast);
thread.set_speed(new_speed, cx);
+ let favorite_key = thread
+ .model()
+ .map(|model| (model.provider_id().0.to_string(), model.id().0.to_string()));
let fs = thread.project().read(cx).fs().clone();
update_settings_file(fs, cx, move |settings, _| {
- if let Some(agent) = settings.agent.as_mut()
- && let Some(default_model) = agent.default_model.as_mut()
- {
- default_model.speed = Some(new_speed);
+ if let Some(agent) = settings.agent.as_mut() {
+ if let Some(default_model) = agent.default_model.as_mut() {
+ default_model.speed = Some(new_speed);
+ }
+ if let Some((provider_id, model_id)) = &favorite_key {
+ agent.update_favorite_model(provider_id, model_id, |favorite| {
+ favorite.speed = Some(new_speed)
+ });
+ }
}
});
});
@@ -8904,12 +8964,20 @@ impl ThreadView {
thread.update(cx, |thread, cx| {
thread.set_thinking_effort(Some(next_effort.clone()), cx);
+ let favorite_key = thread
+ .model()
+ .map(|model| (model.provider_id().0.to_string(), model.id().0.to_string()));
let fs = thread.project().read(cx).fs().clone();
update_settings_file(fs, cx, move |settings, _| {
- if let Some(agent) = settings.agent.as_mut()
- && let Some(default_model) = agent.default_model.as_mut()
- {
- default_model.effort = Some(next_effort);
+ if let Some(agent) = settings.agent.as_mut() {
+ if let Some(default_model) = agent.default_model.as_mut() {
+ default_model.effort = Some(next_effort.clone());
+ }
+ if let Some((provider_id, model_id)) = &favorite_key {
+ agent.update_favorite_model(provider_id, model_id, |favorite| {
+ favorite.effort = Some(next_effort)
+ });
+ }
}
});
});
diff --git a/crates/agent_ui/src/favorite_models.rs b/crates/agent_ui/src/favorite_models.rs
index aa48ca8d12459b2860982a6b204a5350e6c4ce4c..c655f9b6a55aecb7e7ce6e419702e3d0cc6983c2 100644
--- a/crates/agent_ui/src/favorite_models.rs
+++ b/crates/agent_ui/src/favorite_models.rs
@@ -1,27 +1,27 @@
use std::sync::Arc;
+use agent_settings::{AgentSettings, language_model_to_selection};
use fs::Fs;
use language_model::LanguageModel;
-use settings::{LanguageModelSelection, update_settings_file};
+use settings::{Settings as _, update_settings_file};
use ui::App;
-fn language_model_to_selection(model: &Arc) -> LanguageModelSelection {
- LanguageModelSelection {
- provider: model.provider_id().to_string().into(),
- model: model.id().0.to_string(),
- enable_thinking: false,
- effort: None,
- speed: None,
- }
-}
-
pub fn toggle_in_settings(
model: Arc,
should_be_favorite: bool,
fs: Arc,
cx: &mut App,
) {
- let selection = language_model_to_selection(&model);
+ let current_user_selection = AgentSettings::get_global(cx)
+ .default_model
+ .as_ref()
+ .filter(|selection| {
+ selection.provider.0 == model.provider_id().0.as_ref()
+ && selection.model == model.id().0.as_ref()
+ })
+ .cloned();
+
+ let selection = language_model_to_selection(&model, current_user_selection.as_ref());
update_settings_file(fs, cx, move |settings, _| {
let agent = settings.agent.get_or_insert_default();
if should_be_favorite {
diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs
index 899542245ab8f3618f6d70d807363cc91af3a257..7de58fd54ffd0d984b3a6079681f15f6a56507ae 100644
--- a/crates/agent_ui/src/language_model_selector.rs
+++ b/crates/agent_ui/src/language_model_selector.rs
@@ -8,8 +8,8 @@ use gpui::{
Subscription, Task,
};
use language_model::{
- AuthenticateError, ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId,
- LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
+ ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId, LanguageModelProvider,
+ LanguageModelProviderId, LanguageModelRegistry,
};
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
@@ -124,7 +124,6 @@ pub struct LanguageModelPickerDelegate {
all_models: Arc,
filtered_entries: Vec,
selected_index: usize,
- _authenticate_all_providers_task: Task<()>,
_subscriptions: Vec,
popover_styles: bool,
focus_handle: FocusHandle,
@@ -151,7 +150,6 @@ impl LanguageModelPickerDelegate {
filtered_entries: entries,
get_active_model: Arc::new(get_active_model),
on_toggle_favorite: Arc::new(on_toggle_favorite),
- _authenticate_all_providers_task: Self::authenticate_all_providers(cx),
_subscriptions: vec![cx.subscribe_in(
&LanguageModelRegistry::global(cx),
window,
@@ -197,56 +195,6 @@ impl LanguageModelPickerDelegate {
.unwrap_or(0)
}
- /// Authenticates all providers in the [`LanguageModelRegistry`].
- ///
- /// We do this so that we can populate the language selector with all of the
- /// models from the configured providers.
- fn authenticate_all_providers(cx: &mut App) -> Task<()> {
- let authenticate_all_providers = LanguageModelRegistry::global(cx)
- .read(cx)
- .visible_providers()
- .iter()
- .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
- .collect::>();
-
- cx.spawn(async move |_cx| {
- for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
- if let Err(err) = authenticate_task.await {
- if matches!(err, AuthenticateError::CredentialsNotFound) {
- // Since we're authenticating these providers in the
- // background for the purposes of populating the
- // language selector, we don't care about providers
- // where the credentials are not found.
- } else {
- // Some providers have noisy failure states that we
- // don't want to spam the logs with every time the
- // language model selector is initialized.
- //
- // Ideally these should have more clear failure modes
- // that we know are safe to ignore here, like what we do
- // with `CredentialsNotFound` above.
- match provider_id.0.as_ref() {
- "lmstudio" | "ollama" => {
- // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated".
- //
- // These fail noisily, so we don't log them.
- }
- "copilot_chat" => {
- // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors.
- }
- _ => {
- log::error!(
- "Failed to authenticate provider: {}: {err:#}",
- provider_name.0
- );
- }
- }
- }
- }
- }
- })
- }
-
pub fn active_model(&self, cx: &App) -> Option {
(self.get_active_model)(cx)
}
diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs
index 8facafecd9518eafcbf2a9e0486674e0abcd9ebc..1cebd175be46eaabd420853a3997ae1fd6ce7a50 100644
--- a/crates/agent_ui/src/thread_history_view.rs
+++ b/crates/agent_ui/src/thread_history_view.rs
@@ -74,7 +74,7 @@ impl ThreadHistoryView {
) -> Self {
let search_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
- editor.set_placeholder_text("Search threads...", window, cx);
+ editor.set_placeholder_text("Search all threads…", window, cx);
editor
});
diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs
index 3c05c77820cc6e73b1747941480310a7ce1f1de8..a8bd95916a7111afe4a7a75f11ff78f3547b4398 100644
--- a/crates/agent_ui/src/thread_import.rs
+++ b/crates/agent_ui/src/thread_import.rs
@@ -11,7 +11,7 @@ use gpui::{
App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent,
Render, SharedString, Task, WeakEntity, Window,
};
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
use project::{AgentId, AgentRegistryStore, AgentServerStore};
use release_channel::ReleaseChannel;
use remote::RemoteConnectionOptions;
@@ -275,8 +275,12 @@ impl ThreadImportModal {
fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) {
let status_toast = if imported_count == 0 {
StatusToast::new("No threads found to import.", cx, |this, _cx| {
- this.icon(ToastIcon::new(IconName::Info).color(Color::Muted))
- .dismiss_button(true)
+ this.icon(
+ Icon::new(IconName::Info)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .dismiss_button(true)
})
} else {
let message = if imported_count == 1 {
@@ -285,8 +289,12 @@ impl ThreadImportModal {
format!("Imported {imported_count} threads.")
};
StatusToast::new(message, cx, |this, _cx| {
- this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
- .dismiss_button(true)
+ this.icon(
+ Icon::new(IconName::Check)
+ .size(IconSize::Small)
+ .color(Color::Success),
+ )
+ .dismiss_button(true)
})
};
@@ -383,7 +391,7 @@ impl Render for ThreadImportModal {
.headline("Import External Agent Threads")
.description(
"Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client. \
- Choose which agents to include, and their threads will appear in your archive."
+ Choose which agents to include, and their threads will appear in your thread history."
)
.show_dismiss_button(true),
@@ -661,7 +669,7 @@ fn show_cross_channel_import_toast(
) {
let status_toast = if imported_count == 0 {
StatusToast::new("No new threads found to import.", cx, |this, _cx| {
- this.icon(ToastIcon::new(IconName::Info).color(Color::Muted))
+ this.icon(Icon::new(IconName::Info).color(Color::Muted))
.dismiss_button(true)
})
} else {
@@ -671,7 +679,7 @@ fn show_cross_channel_import_toast(
format!("Imported {imported_count} threads from other channels.")
};
StatusToast::new(message, cx, |this, _cx| {
- this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
+ this.icon(Icon::new(IconName::Check).color(Color::Success))
.dismiss_button(true)
})
};
diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs
index 0377a8d2675a4c32e26a70f48f3e6b93561deebd..8a65a682ac806384cc9048964377ada9c9df2e41 100644
--- a/crates/agent_ui/src/thread_metadata_store.rs
+++ b/crates/agent_ui/src/thread_metadata_store.rs
@@ -346,7 +346,7 @@ pub fn worktree_info_from_thread_paths(
.unwrap_or_default();
linked_short_names.push((short_name.clone(), project_name));
infos.push(ThreadItemWorktreeInfo {
- name: short_name,
+ worktree_name: Some(short_name),
full_path: SharedString::from(folder_path.display().to_string()),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
@@ -357,7 +357,7 @@ pub fn worktree_info_from_thread_paths(
continue;
};
infos.push(ThreadItemWorktreeInfo {
- name: SharedString::from(name.to_string_lossy().to_string()),
+ worktree_name: Some(SharedString::from(name.to_string_lossy().to_string())),
full_path: SharedString::from(folder_path.display().to_string()),
highlight_positions: Vec::new(),
kind: WorktreeKind::Main,
@@ -370,7 +370,10 @@ pub fn worktree_info_from_thread_paths(
// folder paths don't all share the same short name, prefix each
// linked worktree chip with its main project name so the user knows
// which project it belongs to.
- let all_same_name = infos.len() > 1 && infos.iter().all(|i| i.name == infos[0].name);
+ let all_same_name = infos.len() > 1
+ && infos
+ .iter()
+ .all(|i| i.worktree_name == infos[0].worktree_name);
if unique_main_count.len() > 1 && !all_same_name {
for (info, (_short_name, project_name)) in infos
@@ -378,7 +381,9 @@ pub fn worktree_info_from_thread_paths(
.filter(|i| i.kind == WorktreeKind::Linked)
.zip(linked_short_names.iter())
{
- info.name = SharedString::from(format!("{}:{}", project_name, info.name));
+ if let Some(name) = &info.worktree_name {
+ info.worktree_name = Some(SharedString::from(format!("{}:{}", project_name, name)));
+ }
}
}
@@ -1171,7 +1176,9 @@ impl ThreadMetadataStore {
.and_then(|t| t.created_at)
.unwrap_or_else(|| updated_at);
- let interacted_at = existing_thread.and_then(|t| t.interacted_at);
+ let interacted_at = existing_thread
+ .map(|t| t.interacted_at)
+ .unwrap_or(Some(updated_at));
let agent_id = thread_ref.connection().agent_id();
diff --git a/crates/agent_ui/src/thread_worktree_archive.rs b/crates/agent_ui/src/thread_worktree_archive.rs
index d131daf5cc5af410a63a854ef4821efbc8f7180d..bc2dfdd07114dc20bae64deb43c86acec3635376 100644
--- a/crates/agent_ui/src/thread_worktree_archive.rs
+++ b/crates/agent_ui/src/thread_worktree_archive.rs
@@ -247,78 +247,9 @@ async fn remove_root_after_worktree_removal(
// alive until the repo removes the worktree
drop(project);
result.context("git worktree metadata cleanup failed")?;
-
- // Empty-parent cleanup uses local std::fs — skip for remote projects.
- if root.remote_connection.is_none() {
- remove_empty_parent_dirs_up_to_worktrees_base(
- root.root_path.clone(),
- root.main_repo_path.clone(),
- cx,
- )
- .await;
- }
-
Ok(())
}
-/// After `git worktree remove` deletes the worktree directory, clean up any
-/// empty parent directories between it and the Zed-managed worktrees base
-/// directory (configured via `git.worktree_directory`). The base directory
-/// itself is never removed.
-///
-/// If the base directory is not an ancestor of `root_path`, no parent
-/// directories are removed.
-async fn remove_empty_parent_dirs_up_to_worktrees_base(
- root_path: PathBuf,
- main_repo_path: PathBuf,
- cx: &mut AsyncApp,
-) {
- let worktrees_base = cx.update(|cx| worktrees_base_for_repo(&main_repo_path, cx));
-
- if let Some(worktrees_base) = worktrees_base {
- cx.background_executor()
- .spawn(async move {
- remove_empty_ancestors(&root_path, &worktrees_base);
- })
- .await;
- }
-}
-
-/// Removes empty directories between `child_path` and `base_path`.
-///
-/// Walks upward from `child_path`, removing each empty parent directory,
-/// stopping before `base_path` itself is removed. If `base_path` is not
-/// an ancestor of `child_path`, nothing is removed. If any directory is
-/// non-empty (i.e. `std::fs::remove_dir` fails), the walk stops.
-fn remove_empty_ancestors(child_path: &Path, base_path: &Path) {
- let mut current = child_path;
- while let Some(parent) = current.parent() {
- if parent == base_path {
- break;
- }
- if !parent.starts_with(base_path) {
- break;
- }
- match std::fs::remove_dir(parent) {
- Ok(()) => {
- log::info!("Removed empty parent directory: {}", parent.display());
- }
- Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
- Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
- // Already removed by a concurrent process; keep walking upward.
- }
- Err(err) => {
- log::error!(
- "Failed to remove parent directory {}: {err}",
- parent.display()
- );
- break;
- }
- }
- current = parent;
- }
-}
-
/// Finds a live `Repository` entity for the given path, or creates a temporary
/// project to obtain one.
///
@@ -913,7 +844,6 @@ mod tests {
use project::Project;
use serde_json::json;
use settings::SettingsStore;
- use tempfile::TempDir;
use workspace::MultiWorkspace;
fn init_test(cx: &mut TestAppContext) {
@@ -926,117 +856,6 @@ mod tests {
});
}
- #[test]
- fn test_remove_empty_ancestors_single_empty_parent() {
- let tmp = TempDir::new().unwrap();
- let base = tmp.path().join("worktrees");
- let branch_dir = base.join("my-branch");
- let child = branch_dir.join("zed");
-
- std::fs::create_dir_all(&child).unwrap();
- // Simulate git worktree remove having deleted the child.
- std::fs::remove_dir(&child).unwrap();
-
- assert!(branch_dir.exists());
- remove_empty_ancestors(&child, &base);
- assert!(!branch_dir.exists(), "empty parent should be removed");
- assert!(base.exists(), "base directory should be preserved");
- }
-
- #[test]
- fn test_remove_empty_ancestors_nested_empty_parents() {
- let tmp = TempDir::new().unwrap();
- let base = tmp.path().join("worktrees");
- // Branch name with slash creates nested dirs: fix/thing/zed
- let child = base.join("fix").join("thing").join("zed");
-
- std::fs::create_dir_all(&child).unwrap();
- std::fs::remove_dir(&child).unwrap();
-
- assert!(base.join("fix").join("thing").exists());
- remove_empty_ancestors(&child, &base);
- assert!(!base.join("fix").join("thing").exists());
- assert!(
- !base.join("fix").exists(),
- "all empty ancestors should be removed"
- );
- assert!(base.exists(), "base directory should be preserved");
- }
-
- #[test]
- fn test_remove_empty_ancestors_stops_at_non_empty_parent() {
- let tmp = TempDir::new().unwrap();
- let base = tmp.path().join("worktrees");
- let branch_dir = base.join("my-branch");
- let child = branch_dir.join("zed");
- let sibling = branch_dir.join("other-file.txt");
-
- std::fs::create_dir_all(&child).unwrap();
- std::fs::write(&sibling, "content").unwrap();
- std::fs::remove_dir(&child).unwrap();
-
- remove_empty_ancestors(&child, &base);
- assert!(branch_dir.exists(), "non-empty parent should be preserved");
- assert!(sibling.exists());
- }
-
- #[test]
- fn test_remove_empty_ancestors_not_an_ancestor() {
- let tmp = TempDir::new().unwrap();
- let base = tmp.path().join("worktrees");
- let unrelated = tmp.path().join("other-place").join("branch").join("zed");
-
- std::fs::create_dir_all(&base).unwrap();
- std::fs::create_dir_all(&unrelated).unwrap();
- std::fs::remove_dir(&unrelated).unwrap();
-
- let parent = unrelated.parent().unwrap();
- assert!(parent.exists());
- remove_empty_ancestors(&unrelated, &base);
- assert!(parent.exists(), "should not remove dirs outside base");
- }
-
- #[test]
- fn test_remove_empty_ancestors_child_is_direct_child_of_base() {
- let tmp = TempDir::new().unwrap();
- let base = tmp.path().join("worktrees");
- let child = base.join("zed");
-
- std::fs::create_dir_all(&child).unwrap();
- std::fs::remove_dir(&child).unwrap();
-
- remove_empty_ancestors(&child, &base);
- assert!(base.exists(), "base directory should be preserved");
- }
-
- #[test]
- fn test_remove_empty_ancestors_partially_non_empty_chain() {
- let tmp = TempDir::new().unwrap();
- let base = tmp.path().join("worktrees");
- // Structure: base/a/b/c/zed where a/ has another child besides b/
- let child = base.join("a").join("b").join("c").join("zed");
- let other_in_a = base.join("a").join("other-branch");
-
- std::fs::create_dir_all(&child).unwrap();
- std::fs::create_dir_all(&other_in_a).unwrap();
- std::fs::remove_dir(&child).unwrap();
-
- remove_empty_ancestors(&child, &base);
- assert!(
- !base.join("a").join("b").join("c").exists(),
- "c/ should be removed (empty)"
- );
- assert!(
- !base.join("a").join("b").exists(),
- "b/ should be removed (empty)"
- );
- assert!(
- base.join("a").exists(),
- "a/ should be preserved (has other-branch sibling)"
- );
- assert!(other_in_a.exists());
- }
-
#[gpui::test]
async fn test_build_root_plan_returns_none_for_main_worktree(cx: &mut TestAppContext) {
init_test(cx);
diff --git a/crates/agent_ui/src/thread_worktree_picker.rs b/crates/agent_ui/src/thread_worktree_picker.rs
index c77da77d0d353e27d8e45d896e9657a853633e05..93d04fd131d4241d15c2fbb0af96b5d69d3920af 100644
--- a/crates/agent_ui/src/thread_worktree_picker.rs
+++ b/crates/agent_ui/src/thread_worktree_picker.rs
@@ -361,7 +361,6 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
}
// When the user is typing, fuzzy-match worktree names using display_name
- // For the main worktree, also match against "main"
let main_worktree_path = repo_worktrees
.iter()
.find(|wt| wt.is_main)
diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs
index aa082a0c23e524c142426bde171a7ed28aa32cf7..b86d19f2013442690ebc3ca80afb71ae8b576e0e 100644
--- a/crates/agent_ui/src/threads_archive_view.rs
+++ b/crates/agent_ui/src/threads_archive_view.rs
@@ -7,7 +7,7 @@ use crate::agent_connection_store::AgentConnectionStore;
use crate::thread_metadata_store::{
ThreadId, ThreadMetadata, ThreadMetadataStore, worktree_info_from_thread_paths,
};
-use crate::{Agent, DEFAULT_THREAD_TITLE, RemoveSelectedThread};
+use crate::{Agent, ArchiveSelectedThread, DEFAULT_THREAD_TITLE, RemoveSelectedThread};
use agent::ThreadStore;
use agent_client_protocol as acp;
@@ -30,10 +30,9 @@ use picker::{
use project::{AgentId, AgentServerStore};
use settings::Settings as _;
use theme::ActiveTheme;
-use ui::{AgentThreadStatus, IconDecoration, IconDecorationKind, Tab, ThreadItem};
use ui::{
- Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, WithScrollbar,
- prelude::*, utils::platform_title_bar_height,
+ AgentThreadStatus, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tab,
+ ThreadItem, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height,
};
use ui_input::ErasedEditor;
use util::ResultExt;
@@ -46,6 +45,13 @@ use workspace::{
use zed_actions::agents_sidebar::FocusSidebarFilter;
use zed_actions::editor::{MoveDown, MoveUp};
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+enum ThreadFilter {
+ #[default]
+ All,
+ ArchivedOnly,
+}
+
#[derive(Clone)]
enum ArchiveListItem {
BucketSeparator(TimeBucket),
@@ -118,6 +124,7 @@ pub enum ThreadsArchiveViewEvent {
Close,
Activate { thread: ThreadMetadata },
CancelRestore { thread_id: ThreadId },
+ Import,
}
impl EventEmitter for ThreadsArchiveView {}
@@ -140,7 +147,7 @@ pub struct ThreadsArchiveView {
archived_thread_ids: HashSet,
archived_branch_names: HashMap>,
_load_branch_names_task: Task<()>,
- show_archived_only: bool,
+ thread_filter: ThreadFilter,
}
impl ThreadsArchiveView {
@@ -155,7 +162,7 @@ impl ThreadsArchiveView {
let filter_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
- editor.set_placeholder_text("Search threads…", window, cx);
+ editor.set_placeholder_text("Search all threads…", window, cx);
editor
});
@@ -214,7 +221,7 @@ impl ThreadsArchiveView {
archived_thread_ids: HashSet::default(),
archived_branch_names: HashMap::default(),
_load_branch_names_task: Task::ready(()),
- show_archived_only: false,
+ thread_filter: ThreadFilter::All,
};
this.update_items(cx);
@@ -253,11 +260,14 @@ impl ThreadsArchiveView {
}
fn update_items(&mut self, cx: &mut Context) {
- let show_archived_only = self.show_archived_only;
+ let thread_filter = self.thread_filter;
let sessions = ThreadMetadataStore::global(cx)
.read(cx)
.entries()
- .filter(|t| !show_archived_only || t.archived)
+ .filter(|t| match thread_filter {
+ ThreadFilter::All => true,
+ ThreadFilter::ArchivedOnly => t.archived,
+ })
.sorted_by_cached_key(|t| t.created_at.unwrap_or(t.updated_at))
.rev()
.cloned()
@@ -310,11 +320,7 @@ impl ThreadsArchiveView {
let preserve = self.preserve_selection_on_next_update;
self.preserve_selection_on_next_update = false;
- let saved_scroll = if preserve {
- Some(self.list_state.logical_scroll_top())
- } else {
- None
- };
+ let saved_scroll = self.list_state.logical_scroll_top();
self.list_state.reset(items.len());
self.items = items;
@@ -327,9 +333,9 @@ impl ThreadsArchiveView {
}
}
- if let Some(scroll_top) = saved_scroll {
- self.list_state.scroll_to(scroll_top);
+ self.list_state.scroll_to(saved_scroll);
+ if preserve {
if let Some(ix) = self.selection {
let next = self.find_next_selectable(ix).or_else(|| {
ix.checked_sub(1)
@@ -389,6 +395,24 @@ impl ThreadsArchiveView {
ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(thread_id, None, cx));
}
+ fn archive_selected_thread(
+ &mut self,
+ _: &ArchiveSelectedThread,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let Some(ix) = self.selection else { return };
+ let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
+ return;
+ };
+
+ if thread.archived {
+ return;
+ }
+
+ self.archive_thread(thread.thread_id, cx);
+ }
+
fn unarchive_thread(
&mut self,
thread: ThreadMetadata,
@@ -606,24 +630,14 @@ impl ThreadsArchiveView {
&branch_names_for_thread,
);
- let color = cx.theme().colors();
- let knockout_color = color
- .title_bar_background
- .blend(color.panel_background.opacity(0.25));
- let archived_decoration =
- IconDecoration::new(IconDecorationKind::Archive, knockout_color, cx)
- .color(color.icon_disabled)
- .position(gpui::Point {
- x: px(-3.),
- y: px(-3.5),
- });
+ let archived_color = Color::Custom(cx.theme().colors().icon_muted.opacity(0.6));
let base = ThreadItem::new(id, thread.display_title())
.icon(icon)
.when(is_archived, |this| {
- this.icon_color(Color::Muted)
+ this.archived(true)
+ .icon_color(archived_color)
.title_label_color(Color::Muted)
- .icon_decoration(archived_decoration)
})
.when_some(icon_from_external_svg, |this, svg| {
this.custom_icon_from_external_svg(svg)
@@ -661,7 +675,6 @@ impl ThreadsArchiveView {
})
}),
)
- .tooltip(Tooltip::text("Restoring…"))
.into_any_element()
} else if is_archived {
base.action_slot(
@@ -694,9 +707,6 @@ impl ThreadsArchiveView {
})
}),
)
- .tooltip(move |_, cx| {
- Tooltip::for_action("Open Archived Thread", &menu::Confirm, cx)
- })
.on_click({
let thread = thread.clone();
cx.listener(move |this, _, window, cx| {
@@ -709,7 +719,16 @@ impl ThreadsArchiveView {
IconButton::new("archive-thread", IconName::Archive)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
- .tooltip(Tooltip::text("Archive Thread"))
+ .tooltip({
+ move |_window, cx| {
+ Tooltip::for_action_in(
+ "Archive Thread",
+ &ArchiveSelectedThread,
+ &focus_handle,
+ cx,
+ )
+ }
+ })
.on_click({
let thread_id = thread.thread_id;
cx.listener(move |this, _, _, cx| {
@@ -718,7 +737,6 @@ impl ThreadsArchiveView {
})
}),
)
- .tooltip(move |_, cx| Tooltip::for_action("Open Thread", &menu::Confirm, cx))
.on_click({
let thread = thread.clone();
cx.listener(move |this, _, window, cx| {
@@ -869,14 +887,13 @@ impl ThreadsArchiveView {
.filter(|item| matches!(item, ArchiveListItem::Entry { .. }))
.count();
+ let has_archived_threads = {
+ let store = ThreadMetadataStore::global(cx).read(cx);
+ store.archived_entries().next().is_some()
+ };
+
let count_label = if entry_count == 1 {
- if self.show_archived_only {
- "1 archived thread".to_string()
- } else {
- "1 thread".to_string()
- }
- } else if self.show_archived_only {
- format!("{} archived threads", entry_count)
+ "1 thread".to_string()
} else {
format!("{} threads", entry_count)
};
@@ -895,18 +912,37 @@ impl ThreadsArchiveView {
.color(Color::Muted),
)
.child(
- IconButton::new("toggle-archived-only", IconName::ListFilter)
- .icon_size(IconSize::Small)
- .toggle_state(self.show_archived_only)
- .tooltip(Tooltip::text(if self.show_archived_only {
- "Show All Threads"
- } else {
- "Show Archived Only"
- }))
- .on_click(cx.listener(|this, _, _, cx| {
- this.show_archived_only = !this.show_archived_only;
- this.update_items(cx);
- })),
+ h_flex()
+ .child(
+ IconButton::new("thread-import", IconName::Download)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Import Threads"))
+ .on_click(cx.listener(|_this, _, _, cx| {
+ cx.emit(ThreadsArchiveViewEvent::Import);
+ })),
+ )
+ .child(
+ IconButton::new("filter-archived-only", IconName::Archive)
+ .icon_size(IconSize::Small)
+ .disabled(!has_archived_threads)
+ .toggle_state(self.thread_filter == ThreadFilter::ArchivedOnly)
+ .tooltip(Tooltip::text(
+ if self.thread_filter == ThreadFilter::ArchivedOnly {
+ "Show All Threads"
+ } else {
+ "Show Only Archived Threads"
+ },
+ ))
+ .on_click(cx.listener(|this, _, _, cx| {
+ this.thread_filter =
+ if this.thread_filter == ThreadFilter::ArchivedOnly {
+ ThreadFilter::All
+ } else {
+ ThreadFilter::ArchivedOnly
+ };
+ this.update_items(cx);
+ })),
+ ),
)
}
}
@@ -989,6 +1025,7 @@ impl Render for ThreadsArchiveView {
.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(Self::archive_selected_thread))
.size_full()
.child(self.render_header(window, cx))
.when(!has_query, |this| this.child(self.render_toolbar(cx)))
diff --git a/crates/agent_ui/src/ui/undo_reject_toast.rs b/crates/agent_ui/src/ui/undo_reject_toast.rs
index 90c8a9c7ea98edd56ca935eddb36206a83bcc4bc..97352fa67d690d5770442c662e7e3145dcf25bf5 100644
--- a/crates/agent_ui/src/ui/undo_reject_toast.rs
+++ b/crates/agent_ui/src/ui/undo_reject_toast.rs
@@ -1,6 +1,6 @@
use action_log::ActionLog;
use gpui::{App, Entity};
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
use ui::prelude::*;
use workspace::Workspace;
@@ -11,15 +11,19 @@ pub fn show_undo_reject_toast(
) {
let action_log_weak = action_log.downgrade();
let status_toast = StatusToast::new("Agent Changes Rejected", cx, move |this, _cx| {
- this.icon(ToastIcon::new(IconName::Undo).color(Color::Muted))
- .action("Undo", move |_window, cx| {
- if let Some(action_log) = action_log_weak.upgrade() {
- action_log
- .update(cx, |action_log, cx| action_log.undo_last_reject(cx))
- .detach();
- }
- })
- .dismiss_button(true)
+ this.icon(
+ Icon::new(IconName::Undo)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .action("Undo", move |_window, cx| {
+ if let Some(action_log) = action_log_weak.upgrade() {
+ action_log
+ .update(cx, |action_log, cx| action_log.undo_last_reject(cx))
+ .detach();
+ }
+ })
+ .dismiss_button(true)
});
workspace.toggle_status_toast(status_toast, cx);
}
diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs
index c49558e50472f3b497ba74ac388f3874aceec77b..147458923045c15faf926a1ad4424b666b5204d8 100644
--- a/crates/ai_onboarding/src/ai_onboarding.rs
+++ b/crates/ai_onboarding/src/ai_onboarding.rs
@@ -17,9 +17,7 @@ use std::sync::Arc;
use client::{Client, UserStore, zed_urls};
use gpui::{AnyElement, Entity, IntoElement, ParentElement};
-use ui::{
- Divider, List, ListBulletItem, RegisterComponent, Tooltip, Vector, VectorName, prelude::*,
-};
+use ui::{Divider, RegisterComponent, Tooltip, Vector, VectorName, prelude::*};
#[derive(PartialEq)]
pub enum SignInStatus {
@@ -442,131 +440,3 @@ impl Component for ZedAiOnboarding {
)
}
}
-
-#[derive(RegisterComponent)]
-pub struct AgentLayoutOnboarding {
- pub use_agent_layout: Arc,
- pub revert_to_editor_layout: Arc,
- pub dismissed: Arc,
- pub is_agent_layout: bool,
-}
-
-impl Render for AgentLayoutOnboarding {
- fn render(&mut self, _window: &mut ui::Window, _cx: &mut Context) -> impl IntoElement {
- let description = "With the new Threads Sidebar, you can manage multiple agents across several projects, all in one window.";
-
- let dismiss_button = div().absolute().top_0().right_0().child(
- IconButton::new("dismiss", IconName::Close)
- .icon_size(IconSize::Small)
- .on_click({
- let dismiss = self.dismissed.clone();
- move |_, window, cx| {
- telemetry::event!("Agentic Layout Onboarding Dismissed");
- dismiss(window, cx)
- }
- }),
- );
-
- let primary_button = if self.is_agent_layout {
- Button::new("revert", "Use Previous Layout")
- .label_size(LabelSize::Small)
- .style(ButtonStyle::Outlined)
- .on_click({
- let revert = self.revert_to_editor_layout.clone();
- let dismiss = self.dismissed.clone();
- move |_, window, cx| {
- telemetry::event!("Clicked to Use Previous Layout");
- revert(window, cx);
- dismiss(window, cx);
- }
- })
- } else {
- Button::new("start", "Use New Layout")
- .label_size(LabelSize::Small)
- .style(ButtonStyle::Outlined)
- .on_click({
- let use_layout = self.use_agent_layout.clone();
- let dismiss = self.dismissed.clone();
- move |_, window, cx| {
- telemetry::event!("Clicked to Use New Layout");
- use_layout(window, cx);
- dismiss(window, cx);
- }
- })
- };
-
- let content = v_flex()
- .min_w_0()
- .w_full()
- .relative()
- .gap_1()
- .child(Label::new("A new workspace layout for agentic workflows"))
- .child(Label::new(description).color(Color::Muted).mb_2())
- .child(
- List::new()
- .child(ListBulletItem::new(
- "The Sidebar and Agent Panel are on the left by default",
- ))
- .child(ListBulletItem::new(
- "The Project Panel and all other panels shift to the right",
- ))
- .child(ListBulletItem::new(
- "You can always customize your workspace layout in your Settings",
- )),
- )
- .child(
- h_flex()
- .w_full()
- .gap_1()
- .flex_wrap()
- .justify_end()
- .child(
- Button::new("learn", "Learn More")
- .label_size(LabelSize::Small)
- .style(ButtonStyle::OutlinedGhost)
- .on_click(move |_, _, cx| {
- cx.open_url(&zed_urls::parallel_agents_blog(cx))
- }),
- )
- .child(primary_button),
- )
- .child(dismiss_button);
-
- AgentPanelOnboardingCard::new().child(content)
- }
-}
-
-impl Component for AgentLayoutOnboarding {
- fn scope() -> ComponentScope {
- ComponentScope::Onboarding
- }
-
- fn name() -> &'static str {
- "Agent Layout Onboarding"
- }
-
- fn preview(_window: &mut Window, cx: &mut App) -> Option {
- let onboarding = cx.new(|_cx| AgentLayoutOnboarding {
- use_agent_layout: Arc::new(|_, _| {}),
- revert_to_editor_layout: Arc::new(|_, _| {}),
- dismissed: Arc::new(|_, _| {}),
- is_agent_layout: false,
- });
-
- Some(
- v_flex()
- .min_w_0()
- .gap_4()
- .child(single_example(
- "Agent Layout Onboarding",
- div()
- .w_full()
- .min_w_40()
- .max_w(px(1100.))
- .child(onboarding)
- .into_any_element(),
- ))
- .into_any_element(),
- )
- }
-}
diff --git a/crates/auto_update_ui/Cargo.toml b/crates/auto_update_ui/Cargo.toml
index b7b51c4a28448434ec4483f898e2d67b3301533e..16783aa9e963ee314776d57712fc343e8227f94f 100644
--- a/crates/auto_update_ui/Cargo.toml
+++ b/crates/auto_update_ui/Cargo.toml
@@ -19,6 +19,7 @@ client.workspace = true
db.workspace = true
fs.workspace = true
editor.workspace = true
+notifications.workspace = true
gpui.workspace = true
markdown_preview.workspace = true
release_channel.workspace = true
diff --git a/crates/auto_update_ui/src/auto_update_ui.rs b/crates/auto_update_ui/src/auto_update_ui.rs
index ec199c18a3179ef70c9092cb737dd22c4514ceac..ebd0d2c06dc977a0e0b7cfadd8d787a11ec81e28 100644
--- a/crates/auto_update_ui/src/auto_update_ui.rs
+++ b/crates/auto_update_ui/src/auto_update_ui.rs
@@ -9,6 +9,7 @@ use gpui::{
App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window, actions, prelude::*,
};
use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView};
+use notifications::status_toast::StatusToast;
use release_channel::{AppVersion, ReleaseChannel};
use semver::Version;
use serde::Deserialize;
@@ -207,17 +208,17 @@ fn announcement_for_version(version: &Version, cx: &App) -> Option::global(cx);
Some(AnnouncementContent {
heading: "Introducing Parallel Agents".into(),
- description: "Run multiple agent threads simultaneously across projects.".into(),
+ description: "Run multiple threads of your favorite agents simultaneously across projects in a new workspace layout, tailored for agentic workflows.".into(),
bullet_items: vec![
"Use your favorite agents in parallel".into(),
"Optionally isolate agents using worktrees".into(),
"Combine multiple projects in one window".into(),
],
- primary_action_label: "Try Now".into(),
+ primary_action_label: "Try Agentic Layout".into(),
primary_action_url: None,
primary_action_callback: Some(Arc::new(move |window, cx| {
- let already_agent_layout =
- matches!(AgentSettings::get_layout(cx), WindowLayout::Agent(_));
+ let get_layout = AgentSettings::get_layout(cx);
+ let already_agent_layout = matches!(get_layout, WindowLayout::Agent(_));
let update;
if !already_agent_layout {
@@ -230,6 +231,7 @@ fn announcement_for_version(version: &Version, cx: &App) -> Option Option);
diff --git a/crates/edit_prediction_ui/src/edit_prediction_ui.rs b/crates/edit_prediction_ui/src/edit_prediction_ui.rs
index 0735a8ccab69cfc812b84195adb14743167c651a..2f6280619adafd291c20549e4a9cbaab312a04cf 100644
--- a/crates/edit_prediction_ui/src/edit_prediction_ui.rs
+++ b/crates/edit_prediction_ui/src/edit_prediction_ui.rs
@@ -115,9 +115,9 @@ fn feature_gate_predict_edits_actions(cx: &mut App) {
})
.detach();
- cx.observe_flag::(move |is_enabled, cx| {
+ cx.observe_flag::(move |value, cx| {
if !DisableAiSettings::get_global(cx).disable_ai {
- if is_enabled {
+ if *value {
CommandPaletteFilter::update_global(cx, |filter, _cx| {
filter.show_action_types(&rate_completion_action_types);
});
diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs
index eb071bf955cede173e74993c93ab5cd294338474..8945ab11d7067ca3eaf4b99b37414c2fe552b57c 100644
--- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs
+++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs
@@ -1,7 +1,7 @@
use buffer_diff::BufferDiff;
use edit_prediction::{EditPrediction, EditPredictionRating, EditPredictionStore};
use editor::{Editor, Inlay, MultiBuffer};
-use feature_flags::FeatureFlag;
+use feature_flags::{FeatureFlag, PresenceFlag, register_feature_flag};
use gpui::{
App, BorderStyle, DismissEvent, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable,
Length, StyleRefinement, TextStyleRefinement, Window, actions, prelude::*,
@@ -43,7 +43,9 @@ pub struct PredictEditsRatePredictionsFeatureFlag;
impl FeatureFlag for PredictEditsRatePredictionsFeatureFlag {
const NAME: &'static str = "predict-edits-rate-completions";
+ type Value = PresenceFlag;
}
+register_feature_flag!(PredictEditsRatePredictionsFeatureFlag);
pub struct RatePredictionsModal {
ep_store: Entity,
diff --git a/crates/feature_flags/Cargo.toml b/crates/feature_flags/Cargo.toml
index 960834211ff18980675b236cd0cc2893d563d668..31ec5ec0da8ceb0e249230d0fd5a2994670c7986 100644
--- a/crates/feature_flags/Cargo.toml
+++ b/crates/feature_flags/Cargo.toml
@@ -12,4 +12,15 @@ workspace = true
path = "src/feature_flags.rs"
[dependencies]
+collections.workspace = true
+feature_flags_macros.workspace = true
+fs.workspace = true
gpui.workspace = true
+inventory.workspace = true
+schemars.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+
+[dev-dependencies]
+gpui = { workspace = true, features = ["test-support"] }
+settings = { workspace = true, features = ["test-support"] }
diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs
index 5b8af1180aae812ed1475810acc1920a8ec708f1..ae2980c699fd190543ec53b1950652b42bd66259 100644
--- a/crates/feature_flags/src/feature_flags.rs
+++ b/crates/feature_flags/src/feature_flags.rs
@@ -1,4 +1,10 @@
+// Makes the derive macro's reference to `::feature_flags::FeatureFlagValue`
+// resolve when the macro is invoked inside this crate itself.
+extern crate self as feature_flags;
+
mod flags;
+mod settings;
+mod store;
use std::cell::RefCell;
use std::rc::Rc;
@@ -6,33 +12,98 @@ use std::sync::LazyLock;
use gpui::{App, Context, Global, Subscription, Window};
+pub use feature_flags_macros::EnumFeatureFlag;
pub use flags::*;
-
-#[derive(Default)]
-struct FeatureFlags {
- flags: Vec,
- staff: bool,
-}
+pub use settings::{FeatureFlagsSettings, generate_feature_flags_schema};
+pub use store::*;
pub static ZED_DISABLE_STAFF: LazyLock = LazyLock::new(|| {
std::env::var("ZED_DISABLE_STAFF").is_ok_and(|value| !value.is_empty() && value != "0")
});
-impl FeatureFlags {
- fn has_flag(&self) -> bool {
- if T::enabled_for_all() {
- return true;
+impl Global for FeatureFlagStore {}
+
+pub trait FeatureFlagValue:
+ Sized + Clone + Eq + Default + std::fmt::Debug + Send + Sync + 'static
+{
+ /// Every possible value for this flag, in the order the UI should display them.
+ fn all_variants() -> &'static [Self];
+
+ /// A stable identifier for this variant used when persisting overrides.
+ fn override_key(&self) -> &'static str;
+
+ fn from_wire(wire: &str) -> Option;
+
+ /// Human-readable label for use in the configuration UI.
+ fn label(&self) -> &'static str {
+ self.override_key()
+ }
+
+ /// The variant that represents "on" — what the store resolves to when
+ /// staff rules, `enabled_for_all`, or a server announcement apply.
+ ///
+ /// For enum flags this is usually the same as [`Default::default`] (the
+ /// variant marked `#[default]` in the derive). [`PresenceFlag`] overrides
+ /// this so that `default() == Off` (the "unconfigured" state) but
+ /// `on_variant() == On` (the "enabled" state).
+ fn on_variant() -> Self {
+ Self::default()
+ }
+}
+
+/// Default value type for simple on/off feature flags.
+///
+/// The fallback value is [`PresenceFlag::Off`] so that an absent / unknown
+/// flag reads as disabled; the `on_variant` override pins the "enabled"
+/// state to [`PresenceFlag::On`] so staff / server / `enabled_for_all`
+/// resolution still lights the flag up.
+#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
+pub enum PresenceFlag {
+ On,
+ #[default]
+ Off,
+}
+
+/// Presence flags deref to a `bool` so call sites can use `if *flag` without
+/// spelling out the enum variant — or pass them anywhere a `&bool` is wanted.
+impl std::ops::Deref for PresenceFlag {
+ type Target = bool;
+
+ fn deref(&self) -> &bool {
+ match self {
+ PresenceFlag::On => &true,
+ PresenceFlag::Off => &false,
}
+ }
+}
- if (cfg!(debug_assertions) || self.staff) && !*ZED_DISABLE_STAFF && T::enabled_for_staff() {
- return true;
+impl FeatureFlagValue for PresenceFlag {
+ fn all_variants() -> &'static [Self] {
+ &[PresenceFlag::On, PresenceFlag::Off]
+ }
+
+ fn override_key(&self) -> &'static str {
+ match self {
+ PresenceFlag::On => "on",
+ PresenceFlag::Off => "off",
}
+ }
- self.flags.iter().any(|f| f.as_str() == T::NAME)
+ fn label(&self) -> &'static str {
+ match self {
+ PresenceFlag::On => "On",
+ PresenceFlag::Off => "Off",
+ }
}
-}
-impl Global for FeatureFlags {}
+ fn from_wire(_: &str) -> Option {
+ Some(PresenceFlag::On)
+ }
+
+ fn on_variant() -> Self {
+ PresenceFlag::On
+ }
+}
/// To create a feature flag, implement this trait on a trivial type and use it as
/// a generic parameter when called [`FeatureFlagAppExt::has_flag`].
@@ -43,6 +114,10 @@ impl Global for FeatureFlags {}
pub trait FeatureFlag {
const NAME: &'static str;
+ /// The type of value this flag can hold. Use [`PresenceFlag`] for simple
+ /// on/off flags.
+ type Value: FeatureFlagValue;
+
/// Returns whether this feature flag is enabled for Zed staff.
fn enabled_for_staff() -> bool {
true
@@ -55,12 +130,23 @@ pub trait FeatureFlag {
fn enabled_for_all() -> bool {
false
}
+
+ /// Subscribes the current view to changes in the feature flag store, so
+ /// that any mutation of flags or overrides will trigger a re-render.
+ ///
+ /// The returned subscription is immediately detached; use [`observe_flag`]
+ /// directly if you need to hold onto the subscription.
+ fn watch(cx: &mut Context) {
+ cx.observe_global::(|_, cx| cx.notify())
+ .detach();
+ }
}
pub trait FeatureFlagViewExt {
+ /// Fires the callback whenever the resolved [`T::Value`] transitions.
fn observe_flag(&mut self, window: &Window, callback: F) -> Subscription
where
- F: Fn(bool, &mut V, &mut Window, &mut Context) + Send + Sync + 'static;
+ F: Fn(T::Value, &mut V, &mut Window, &mut Context) + Send + Sync + 'static;
fn when_flag_enabled(
&mut self,
@@ -75,11 +161,16 @@ where
{
fn observe_flag(&mut self, window: &Window, callback: F) -> Subscription
where
- F: Fn(bool, &mut V, &mut Window, &mut Context) + 'static,
+ F: Fn(T::Value, &mut V, &mut Window, &mut Context) + 'static,
{
- self.observe_global_in::(window, move |v, window, cx| {
- let feature_flags = cx.global::();
- callback(feature_flags.has_flag::(), v, window, cx);
+ let mut last_value: Option = None;
+ self.observe_global_in::(window, move |v, window, cx| {
+ let value = cx.flag_value::();
+ if last_value.as_ref() == Some(&value) {
+ return;
+ }
+ last_value = Some(value.clone());
+ callback(value, v, window, cx);
})
}
@@ -89,8 +180,8 @@ where
callback: impl Fn(&mut V, &mut Window, &mut Context) + Send + Sync + 'static,
) {
if self
- .try_global::()
- .is_some_and(|f| f.has_flag::())
+ .try_global::()
+ .is_some_and(|f| f.has_flag::(self))
{
self.defer_in(window, move |view, window, cx| {
callback(view, window, cx);
@@ -98,11 +189,11 @@ where
return;
}
let subscription = Rc::new(RefCell::new(None));
- let inner = self.observe_global_in::(window, {
+ let inner = self.observe_global_in::(window, {
let subscription = subscription.clone();
move |v, window, cx| {
- let feature_flags = cx.global::();
- if feature_flags.has_flag::() {
+ let has_flag = cx.global::().has_flag::(cx);
+ if has_flag {
callback(v, window, cx);
subscription.take();
}
@@ -121,6 +212,7 @@ pub trait FeatureFlagAppExt {
fn update_flags(&mut self, staff: bool, flags: Vec);
fn set_staff(&mut self, staff: bool);
fn has_flag(&self) -> bool;
+ fn flag_value(&self) -> T::Value;
fn is_staff(&self) -> bool;
fn on_flags_ready(&mut self, callback: F) -> Subscription
@@ -129,33 +221,35 @@ pub trait FeatureFlagAppExt {
fn observe_flag(&mut self, callback: F) -> Subscription
where
- F: FnMut(bool, &mut App) + 'static;
+ F: FnMut(T::Value, &mut App) + 'static;
}
impl FeatureFlagAppExt for App {
fn update_flags(&mut self, staff: bool, flags: Vec) {
- let feature_flags = self.default_global::();
- feature_flags.staff = staff;
- feature_flags.flags = flags;
+ let store = self.default_global::();
+ store.update_server_flags(staff, flags);
}
fn set_staff(&mut self, staff: bool) {
- let feature_flags = self.default_global::();
- feature_flags.staff = staff;
+ let store = self.default_global::();
+ store.set_staff(staff);
}
fn has_flag(&self) -> bool {
- self.try_global::()
- .map(|flags| flags.has_flag::())
- .unwrap_or_else(|| {
- (cfg!(debug_assertions) && T::enabled_for_staff() && !*ZED_DISABLE_STAFF)
- || T::enabled_for_all()
- })
+ self.try_global::()
+ .map(|store| store.has_flag::(self))
+ .unwrap_or_else(|| FeatureFlagStore::has_flag_default::())
+ }
+
+ fn flag_value(&self) -> T::Value {
+ self.try_global::()
+ .and_then(|store| store.try_flag_value::(self))
+ .unwrap_or_default()
}
fn is_staff(&self) -> bool {
- self.try_global::()
- .map(|flags| flags.staff)
+ self.try_global::()
+ .map(|store| store.is_staff())
.unwrap_or(false)
}
@@ -163,11 +257,11 @@ impl FeatureFlagAppExt for App {
where
F: FnMut(OnFlagsReady, &mut App) + 'static,
{
- self.observe_global::(move |cx| {
- let feature_flags = cx.global::();
+ self.observe_global::(move |cx| {
+ let store = cx.global::();
callback(
OnFlagsReady {
- is_staff: feature_flags.staff,
+ is_staff: store.is_staff(),
},
cx,
);
@@ -176,11 +270,16 @@ impl FeatureFlagAppExt for App {
fn observe_flag(&mut self, mut callback: F) -> Subscription
where
- F: FnMut(bool, &mut App) + 'static,
+ F: FnMut(T::Value, &mut App) + 'static,
{
- self.observe_global::(move |cx| {
- let feature_flags = cx.global::();
- callback(feature_flags.has_flag::(), cx);
+ let mut last_value: Option = None;
+ self.observe_global::(move |cx| {
+ let value = cx.flag_value::();
+ if last_value.as_ref() == Some(&value) {
+ return;
+ }
+ last_value = Some(value.clone());
+ callback(value, cx);
})
}
}
diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs
index d9541d819626c52861e7679d2e9dff525bfbb1f9..1665e6ffb6c0685370beab589c1bb88f714d70d0 100644
--- a/crates/feature_flags/src/flags.rs
+++ b/crates/feature_flags/src/flags.rs
@@ -1,26 +1,32 @@
-use crate::FeatureFlag;
+use crate::{EnumFeatureFlag, FeatureFlag, PresenceFlag, register_feature_flag};
pub struct NotebookFeatureFlag;
impl FeatureFlag for NotebookFeatureFlag {
const NAME: &'static str = "notebooks";
+ type Value = PresenceFlag;
}
+register_feature_flag!(NotebookFeatureFlag);
pub struct PanicFeatureFlag;
impl FeatureFlag for PanicFeatureFlag {
const NAME: &'static str = "panic";
+ type Value = PresenceFlag;
}
+register_feature_flag!(PanicFeatureFlag);
pub struct AgentV2FeatureFlag;
impl FeatureFlag for AgentV2FeatureFlag {
const NAME: &'static str = "agent-v2";
+ type Value = PresenceFlag;
fn enabled_for_staff() -> bool {
true
}
}
+register_feature_flag!(AgentV2FeatureFlag);
/// A feature flag for granting access to beta ACP features.
///
@@ -29,50 +35,83 @@ pub struct AcpBetaFeatureFlag;
impl FeatureFlag for AcpBetaFeatureFlag {
const NAME: &'static str = "acp-beta";
+ type Value = PresenceFlag;
}
+register_feature_flag!(AcpBetaFeatureFlag);
pub struct AgentSharingFeatureFlag;
impl FeatureFlag for AgentSharingFeatureFlag {
const NAME: &'static str = "agent-sharing";
+ type Value = PresenceFlag;
}
+register_feature_flag!(AgentSharingFeatureFlag);
pub struct DiffReviewFeatureFlag;
impl FeatureFlag for DiffReviewFeatureFlag {
const NAME: &'static str = "diff-review";
+ type Value = PresenceFlag;
fn enabled_for_staff() -> bool {
false
}
}
+register_feature_flag!(DiffReviewFeatureFlag);
pub struct StreamingEditFileToolFeatureFlag;
impl FeatureFlag for StreamingEditFileToolFeatureFlag {
const NAME: &'static str = "streaming-edit-file-tool";
+ type Value = PresenceFlag;
fn enabled_for_staff() -> bool {
true
}
}
+register_feature_flag!(StreamingEditFileToolFeatureFlag);
pub struct UpdatePlanToolFeatureFlag;
impl FeatureFlag for UpdatePlanToolFeatureFlag {
const NAME: &'static str = "update-plan-tool";
+ type Value = PresenceFlag;
fn enabled_for_staff() -> bool {
false
}
}
+register_feature_flag!(UpdatePlanToolFeatureFlag);
pub struct ProjectPanelUndoRedoFeatureFlag;
impl FeatureFlag for ProjectPanelUndoRedoFeatureFlag {
const NAME: &'static str = "project-panel-undo-redo";
+ type Value = PresenceFlag;
fn enabled_for_staff() -> bool {
true
}
}
+register_feature_flag!(ProjectPanelUndoRedoFeatureFlag);
+
+/// Controls how agent thread worktree chips are labeled in the sidebar.
+#[derive(Clone, Copy, PartialEq, Eq, Debug, EnumFeatureFlag)]
+pub enum AgentThreadWorktreeLabel {
+ #[default]
+ Both,
+ Worktree,
+ Branch,
+}
+
+pub struct AgentThreadWorktreeLabelFlag;
+
+impl FeatureFlag for AgentThreadWorktreeLabelFlag {
+ const NAME: &'static str = "agent-thread-worktree-label";
+ type Value = AgentThreadWorktreeLabel;
+
+ fn enabled_for_staff() -> bool {
+ false
+ }
+}
+register_feature_flag!(AgentThreadWorktreeLabelFlag);
diff --git a/crates/feature_flags/src/settings.rs b/crates/feature_flags/src/settings.rs
new file mode 100644
index 0000000000000000000000000000000000000000..13ba699665488ed075b381fce13f6394efbfef37
--- /dev/null
+++ b/crates/feature_flags/src/settings.rs
@@ -0,0 +1,76 @@
+use collections::HashMap;
+use schemars::{Schema, json_schema};
+use serde_json::{Map, Value};
+use settings::{RegisterSetting, Settings, SettingsContent};
+
+use crate::FeatureFlagStore;
+
+#[derive(Clone, Debug, Default, RegisterSetting)]
+pub struct FeatureFlagsSettings {
+ pub overrides: HashMap,
+}
+
+impl Settings for FeatureFlagsSettings {
+ fn from_settings(content: &SettingsContent) -> Self {
+ Self {
+ overrides: content
+ .feature_flags
+ .as_ref()
+ .map(|map| map.0.clone())
+ .unwrap_or_default(),
+ }
+ }
+}
+
+/// Produces a JSON schema for the `feature_flags` object that lists each known
+/// flag as a property with its variant keys as an `enum`.
+///
+/// Unknown flags are permitted via `additionalProperties: { "type": "string" }`,
+/// so removing a flag from the binary never turns existing entries in
+/// `settings.json` into validation errors.
+pub fn generate_feature_flags_schema() -> Schema {
+ let mut properties = Map::new();
+
+ for descriptor in FeatureFlagStore::known_flags() {
+ let variants = (descriptor.variants)();
+ let enum_values: Vec = variants
+ .iter()
+ .map(|v| Value::String(v.override_key.to_string()))
+ .collect();
+ let enum_descriptions: Vec = variants
+ .iter()
+ .map(|v| Value::String(v.label.to_string()))
+ .collect();
+
+ let mut property = Map::new();
+ property.insert("type".to_string(), Value::String("string".to_string()));
+ property.insert("enum".to_string(), Value::Array(enum_values));
+ // VS Code / json-language-server use `enumDescriptions` for hover docs
+ // on each enum value; schemars passes them through untouched.
+ property.insert(
+ "enumDescriptions".to_string(),
+ Value::Array(enum_descriptions),
+ );
+ property.insert(
+ "description".to_string(),
+ Value::String(format!(
+ "Override for the `{}` feature flag. Default: `{}` (the {} variant).",
+ descriptor.name,
+ (descriptor.default_variant_key)(),
+ (descriptor.default_variant_key)(),
+ )),
+ );
+
+ properties.insert(descriptor.name.to_string(), Value::Object(property));
+ }
+
+ json_schema!({
+ "type": "object",
+ "description": "Local overrides for feature flags, keyed by flag name.",
+ "properties": properties,
+ "additionalProperties": {
+ "type": "string",
+ "description": "Unknown feature flag; retained so removed flags don't trip settings validation."
+ }
+ })
+}
diff --git a/crates/feature_flags/src/store.rs b/crates/feature_flags/src/store.rs
new file mode 100644
index 0000000000000000000000000000000000000000..54d261fc7261a06e49051b783ea423db727a3a64
--- /dev/null
+++ b/crates/feature_flags/src/store.rs
@@ -0,0 +1,374 @@
+use std::any::TypeId;
+use std::sync::Arc;
+
+use collections::HashMap;
+use fs::Fs;
+use gpui::{App, BorrowAppContext, Subscription};
+use settings::{Settings, SettingsStore, update_settings_file};
+
+use crate::{FeatureFlag, FeatureFlagValue, FeatureFlagsSettings, ZED_DISABLE_STAFF};
+
+pub struct FeatureFlagDescriptor {
+ pub name: &'static str,
+ pub variants: fn() -> Vec,
+ pub on_variant_key: fn() -> &'static str,
+ pub default_variant_key: fn() -> &'static str,
+ pub enabled_for_all: fn() -> bool,
+ pub enabled_for_staff: fn() -> bool,
+ pub type_id: fn() -> TypeId,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct FeatureFlagVariant {
+ pub override_key: &'static str,
+ pub label: &'static str,
+}
+
+inventory::collect!(FeatureFlagDescriptor);
+
+#[doc(hidden)]
+pub mod __private {
+ pub use inventory;
+}
+
+/// Submits a [`FeatureFlagDescriptor`] for this flag so it shows up in the
+/// configuration UI and in `FeatureFlagStore::known_flags()`.
+#[macro_export]
+macro_rules! register_feature_flag {
+ ($flag:ty) => {
+ $crate::__private::inventory::submit! {
+ $crate::FeatureFlagDescriptor {
+ name: <$flag as $crate::FeatureFlag>::NAME,
+ variants: || {
+ <<$flag as $crate::FeatureFlag>::Value as $crate::FeatureFlagValue>::all_variants()
+ .iter()
+ .map(|v| $crate::FeatureFlagVariant {
+ override_key: <<$flag as $crate::FeatureFlag>::Value as $crate::FeatureFlagValue>::override_key(v),
+ label: <<$flag as $crate::FeatureFlag>::Value as $crate::FeatureFlagValue>::label(v),
+ })
+ .collect()
+ },
+ on_variant_key: || {
+ <<$flag as $crate::FeatureFlag>::Value as $crate::FeatureFlagValue>::override_key(
+ &<<$flag as $crate::FeatureFlag>::Value as $crate::FeatureFlagValue>::on_variant(),
+ )
+ },
+ default_variant_key: || {
+ <<$flag as $crate::FeatureFlag>::Value as $crate::FeatureFlagValue>::override_key(
+ &<<$flag as $crate::FeatureFlag>::Value as ::std::default::Default>::default(),
+ )
+ },
+ enabled_for_all: <$flag as $crate::FeatureFlag>::enabled_for_all,
+ enabled_for_staff: <$flag as $crate::FeatureFlag>::enabled_for_staff,
+ type_id: || std::any::TypeId::of::<$flag>(),
+ }
+ }
+ };
+}
+
+#[derive(Default)]
+pub struct FeatureFlagStore {
+ staff: bool,
+ server_flags: HashMap,
+
+ _settings_subscription: Option,
+}
+
+impl FeatureFlagStore {
+ pub fn init(cx: &mut App) {
+ let subscription = cx.observe_global::(|cx| {
+ // Touch the global so anything observing `FeatureFlagStore` re-runs
+ cx.update_default_global::(|_, _| {});
+ });
+
+ cx.update_default_global::(|store, _| {
+ store._settings_subscription = Some(subscription);
+ });
+ }
+
+ pub fn known_flags() -> impl Iterator- {
+ let mut seen = collections::HashSet::default();
+ inventory::iter::().filter(move |d| seen.insert((d.type_id)()))
+ }
+
+ pub fn is_staff(&self) -> bool {
+ self.staff
+ }
+
+ pub fn set_staff(&mut self, staff: bool) {
+ self.staff = staff;
+ }
+
+ pub fn update_server_flags(&mut self, staff: bool, flags: Vec) {
+ self.staff = staff;
+ self.server_flags.clear();
+ for flag in flags {
+ self.server_flags.insert(flag.clone(), flag);
+ }
+ }
+
+ /// The user's override key for this flag, read directly from
+ /// [`FeatureFlagsSettings`].
+ pub fn override_for<'a>(flag_name: &str, cx: &'a App) -> Option<&'a str> {
+ FeatureFlagsSettings::get_global(cx)
+ .overrides
+ .get(flag_name)
+ .map(String::as_str)
+ }
+
+ /// Applies an override by writing to `settings.json`. The store's own
+ /// `overrides` field will be updated when the settings-store observer
+ /// fires. Pass the [`FeatureFlagValue::override_key`] of the variant
+ /// you want forced.
+ pub fn set_override(flag_name: &str, override_key: String, fs: Arc, cx: &App) {
+ let flag_name = flag_name.to_owned();
+ update_settings_file(fs, cx, move |content, _| {
+ content
+ .feature_flags
+ .get_or_insert_default()
+ .insert(flag_name, override_key);
+ });
+ }
+
+ /// Removes any override for the given flag from `settings.json`. Leaves
+ /// an empty `"feature_flags"` object rather than removing the key
+ /// entirely so the user can see it's still a meaningful settings surface.
+ pub fn clear_override(flag_name: &str, fs: Arc, cx: &App) {
+ let flag_name = flag_name.to_owned();
+ update_settings_file(fs, cx, move |content, _| {
+ if let Some(map) = content.feature_flags.as_mut() {
+ map.remove(&flag_name);
+ }
+ });
+ }
+
+ /// The resolved value of the flag for the current user, taking overrides,
+ /// `enabled_for_all`, staff rules, and server flags into account in that
+ /// order of precedence. Overrides are read directly from
+ /// [`FeatureFlagsSettings`].
+ pub fn try_flag_value(&self, cx: &App) -> Option {
+ // `enabled_for_all` always wins, including over user overrides.
+ if T::enabled_for_all() {
+ return Some(T::Value::on_variant());
+ }
+
+ if let Some(override_key) = FeatureFlagsSettings::get_global(cx).overrides.get(T::NAME) {
+ return variant_from_key::(override_key);
+ }
+
+ // Staff default: resolve to the enabled variant.
+ if (cfg!(debug_assertions) || self.staff) && !*ZED_DISABLE_STAFF && T::enabled_for_staff() {
+ return Some(T::Value::on_variant());
+ }
+
+ // Server-delivered flag.
+ if let Some(wire) = self.server_flags.get(T::NAME) {
+ return T::Value::from_wire(wire);
+ }
+
+ None
+ }
+
+ /// Whether the flag resolves to its "on" value. Best for presence-style
+ /// flags. For enum flags with meaningful non-default variants, prefer
+ /// [`crate::FeatureFlagAppExt::flag_value`].
+ pub fn has_flag(&self, cx: &App) -> bool {
+ self.try_flag_value::(cx)
+ .is_some_and(|v| v == T::Value::on_variant())
+ }
+
+ /// Mirrors the resolution order of [`Self::try_flag_value`], but falls
+ /// back to the [`Default`] variant when no rule applies so the UI always
+ /// shows *something* selected — matching what
+ /// [`crate::FeatureFlagAppExt::flag_value`] would return.
+ pub fn resolved_key(&self, descriptor: &FeatureFlagDescriptor, cx: &App) -> &'static str {
+ let on_variant_key = (descriptor.on_variant_key)();
+
+ if (descriptor.enabled_for_all)() {
+ return on_variant_key;
+ }
+
+ if let Some(requested) = FeatureFlagsSettings::get_global(cx)
+ .overrides
+ .get(descriptor.name)
+ {
+ if let Some(variant) = (descriptor.variants)()
+ .into_iter()
+ .find(|v| v.override_key == requested.as_str())
+ {
+ return variant.override_key;
+ }
+ }
+
+ if (cfg!(debug_assertions) || self.staff)
+ && !*ZED_DISABLE_STAFF
+ && (descriptor.enabled_for_staff)()
+ {
+ return on_variant_key;
+ }
+
+ if self.server_flags.contains_key(descriptor.name) {
+ return on_variant_key;
+ }
+
+ (descriptor.default_variant_key)()
+ }
+
+ /// Whether this flag is forced on by `enabled_for_all` and therefore not
+ /// user-overridable. The UI uses this to render the row as disabled.
+ pub fn is_forced_on(descriptor: &FeatureFlagDescriptor) -> bool {
+ (descriptor.enabled_for_all)()
+ }
+
+ /// Fallback used when the store isn't installed as a global yet (e.g. very
+ /// early in startup). Matches the pre-existing default behavior.
+ pub fn has_flag_default() -> bool {
+ if T::enabled_for_all() {
+ return true;
+ }
+ cfg!(debug_assertions) && T::enabled_for_staff() && !*ZED_DISABLE_STAFF
+ }
+}
+
+fn variant_from_key(key: &str) -> Option {
+ V::all_variants()
+ .iter()
+ .find(|v| v.override_key() == key)
+ .cloned()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{EnumFeatureFlag, FeatureFlag, PresenceFlag};
+ use gpui::UpdateGlobal;
+ use settings::SettingsStore;
+
+ struct DemoFlag;
+ impl FeatureFlag for DemoFlag {
+ const NAME: &'static str = "demo";
+ type Value = PresenceFlag;
+ fn enabled_for_staff() -> bool {
+ false
+ }
+ }
+
+ #[derive(Clone, Copy, PartialEq, Eq, Debug, EnumFeatureFlag)]
+ enum Intensity {
+ #[default]
+ Low,
+ High,
+ }
+
+ struct IntensityFlag;
+ impl FeatureFlag for IntensityFlag {
+ const NAME: &'static str = "intensity";
+ type Value = Intensity;
+ fn enabled_for_all() -> bool {
+ true
+ }
+ }
+
+ fn init_settings_store(cx: &mut App) {
+ let store = SettingsStore::test(cx);
+ cx.set_global(store);
+ SettingsStore::update_global(cx, |store, _| {
+ store.register_setting::();
+ });
+ }
+
+ fn set_override(name: &str, value: &str, cx: &mut App) {
+ SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
+ store.update_user_settings(cx, |content| {
+ content
+ .feature_flags
+ .get_or_insert_default()
+ .insert(name.to_string(), value.to_string());
+ });
+ });
+ }
+
+ #[gpui::test]
+ fn server_flag_enables_presence(cx: &mut App) {
+ init_settings_store(cx);
+ let mut store = FeatureFlagStore::default();
+ assert!(!store.has_flag::(cx));
+ store.update_server_flags(false, vec!["demo".to_string()]);
+ assert!(store.has_flag::(cx));
+ }
+
+ #[gpui::test]
+ fn off_override_beats_server_flag(cx: &mut App) {
+ init_settings_store(cx);
+ let mut store = FeatureFlagStore::default();
+ store.update_server_flags(false, vec!["demo".to_string()]);
+ set_override(DemoFlag::NAME, "off", cx);
+ assert!(!store.has_flag::(cx));
+ assert_eq!(
+ store.try_flag_value::(cx),
+ Some(PresenceFlag::Off)
+ );
+ }
+
+ #[gpui::test]
+ fn enabled_for_all_wins_over_override(cx: &mut App) {
+ init_settings_store(cx);
+ let store = FeatureFlagStore::default();
+ set_override(IntensityFlag::NAME, "high", cx);
+ assert_eq!(
+ store.try_flag_value::(cx),
+ Some(Intensity::Low)
+ );
+ }
+
+ #[gpui::test]
+ fn enum_override_selects_specific_variant(cx: &mut App) {
+ init_settings_store(cx);
+ let store = FeatureFlagStore::default();
+ // Staff path would normally resolve to `Low`; the override pushes
+ // us to `High` instead.
+ set_override("enum-demo", "high", cx);
+
+ struct EnumDemo;
+ impl FeatureFlag for EnumDemo {
+ const NAME: &'static str = "enum-demo";
+ type Value = Intensity;
+ }
+
+ assert_eq!(store.try_flag_value::(cx), Some(Intensity::High));
+ }
+
+ #[gpui::test]
+ fn unknown_variant_key_resolves_to_none(cx: &mut App) {
+ init_settings_store(cx);
+ let store = FeatureFlagStore::default();
+ set_override("enum-demo", "nonsense", cx);
+
+ struct EnumDemo;
+ impl FeatureFlag for EnumDemo {
+ const NAME: &'static str = "enum-demo";
+ type Value = Intensity;
+ }
+
+ assert_eq!(store.try_flag_value::(cx), None);
+ }
+
+ #[gpui::test]
+ fn on_override_enables_without_server_or_staff(cx: &mut App) {
+ init_settings_store(cx);
+ let store = FeatureFlagStore::default();
+ set_override(DemoFlag::NAME, "on", cx);
+ assert!(store.has_flag::(cx));
+ }
+
+ /// No rule applies, so the store's `try_flag_value` returns `None`. The
+ /// `FeatureFlagAppExt::flag_value` path (used by most callers) falls
+ /// back to [`Default`], which for `PresenceFlag` is `Off`.
+ #[gpui::test]
+ fn presence_flag_defaults_to_off(cx: &mut App) {
+ init_settings_store(cx);
+ let store = FeatureFlagStore::default();
+ assert_eq!(store.try_flag_value::(cx), None);
+ assert_eq!(PresenceFlag::default(), PresenceFlag::Off);
+ }
+}
diff --git a/crates/feature_flags_macros/Cargo.toml b/crates/feature_flags_macros/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..794160e68398c15079ebab9362189313ee2450f2
--- /dev/null
+++ b/crates/feature_flags_macros/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "feature_flags_macros"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lib]
+path = "src/feature_flags_macros.rs"
+proc-macro = true
+
+[lints]
+workspace = true
+
+[dependencies]
+proc-macro2.workspace = true
+quote.workspace = true
+syn.workspace = true
diff --git a/crates/feature_flags_macros/LICENSE-GPL b/crates/feature_flags_macros/LICENSE-GPL
new file mode 120000
index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4
--- /dev/null
+++ b/crates/feature_flags_macros/LICENSE-GPL
@@ -0,0 +1 @@
+../../LICENSE-GPL
\ No newline at end of file
diff --git a/crates/feature_flags_macros/src/feature_flags_macros.rs b/crates/feature_flags_macros/src/feature_flags_macros.rs
new file mode 100644
index 0000000000000000000000000000000000000000..0ea8fad6f285688e0108773461e436c73fa4c4e5
--- /dev/null
+++ b/crates/feature_flags_macros/src/feature_flags_macros.rs
@@ -0,0 +1,190 @@
+use proc_macro::TokenStream;
+use proc_macro2::{Span, TokenStream as TokenStream2};
+use quote::quote;
+use syn::{Data, DeriveInput, Fields, Ident, LitStr, parse_macro_input};
+
+/// Derives [`feature_flags::FeatureFlagValue`] for a unit-only enum.
+///
+/// Exactly one variant must be marked with `#[default]`. The default variant
+/// is the one returned when the feature flag is announced by the server,
+/// enabled for all users, or enabled by the staff rule — it's the "on"
+/// value, and also the fallback for `from_wire`.
+///
+/// The generated impl derives:
+///
+/// * `all_variants` — every variant, in source order.
+/// * `override_key` — the variant name, lower-cased with dashes between
+/// PascalCase word boundaries (e.g. `NewWorktree` → `"new-worktree"`).
+/// * `label` — the variant name with PascalCase boundaries expanded to
+/// spaces (e.g. `NewWorktree` → `"New Worktree"`).
+/// * `from_wire` — always returns the default variant, since today the
+/// server wire format is just presence and does not carry a variant.
+///
+/// ## Example
+///
+/// ```ignore
+/// #[derive(Clone, Copy, PartialEq, Eq, Debug, EnumFeatureFlag)]
+/// enum Intensity {
+/// #[default]
+/// Low,
+/// High,
+/// }
+/// ```
+// `attributes(default)` lets users write `#[default]` on a variant even when
+// they're not also deriving `Default`. If `#[derive(Default)]` is present in
+// the same list, it reuses the same attribute — there's no conflict, because
+// helper attributes aren't consumed.
+#[proc_macro_derive(EnumFeatureFlag, attributes(default))]
+pub fn derive_enum_feature_flag(input: TokenStream) -> TokenStream {
+ let input = parse_macro_input!(input as DeriveInput);
+ match expand(&input) {
+ Ok(tokens) => tokens.into(),
+ Err(e) => e.to_compile_error().into(),
+ }
+}
+
+fn expand(input: &DeriveInput) -> syn::Result {
+ let Data::Enum(data) = &input.data else {
+ return Err(syn::Error::new_spanned(
+ input,
+ "EnumFeatureFlag can only be derived for enums",
+ ));
+ };
+
+ if data.variants.is_empty() {
+ return Err(syn::Error::new_spanned(
+ input,
+ "EnumFeatureFlag requires at least one variant",
+ ));
+ }
+
+ let mut default_ident: Option<&Ident> = None;
+ let mut variant_idents: Vec<&Ident> = Vec::new();
+
+ for variant in &data.variants {
+ if !matches!(variant.fields, Fields::Unit) {
+ return Err(syn::Error::new_spanned(
+ variant,
+ "EnumFeatureFlag only supports unit variants (no fields)",
+ ));
+ }
+ if has_default_attr(variant) {
+ if default_ident.is_some() {
+ return Err(syn::Error::new_spanned(
+ variant,
+ "only one variant may be marked with #[default]",
+ ));
+ }
+ default_ident = Some(&variant.ident);
+ }
+ variant_idents.push(&variant.ident);
+ }
+
+ let Some(default_ident) = default_ident else {
+ return Err(syn::Error::new_spanned(
+ input,
+ "EnumFeatureFlag requires exactly one variant to be marked with #[default]",
+ ));
+ };
+
+ let name = &input.ident;
+ let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
+
+ let override_key_arms = variant_idents.iter().map(|variant| {
+ let key = LitStr::new(&to_kebab_case(&variant.to_string()), Span::call_site());
+ quote! { #name::#variant => #key }
+ });
+
+ let label_arms = variant_idents.iter().map(|variant| {
+ let label = LitStr::new(&to_space_separated(&variant.to_string()), Span::call_site());
+ quote! { #name::#variant => #label }
+ });
+
+ let all_variants = variant_idents.iter().map(|v| quote! { #name::#v });
+
+ Ok(quote! {
+ impl #impl_generics ::std::default::Default for #name #ty_generics #where_clause {
+ fn default() -> Self {
+ #name::#default_ident
+ }
+ }
+
+ impl #impl_generics ::feature_flags::FeatureFlagValue for #name #ty_generics #where_clause {
+ fn all_variants() -> &'static [Self] {
+ &[ #( #all_variants ),* ]
+ }
+
+ fn override_key(&self) -> &'static str {
+ match self {
+ #( #override_key_arms ),*
+ }
+ }
+
+ fn label(&self) -> &'static str {
+ match self {
+ #( #label_arms ),*
+ }
+ }
+
+ fn from_wire(_: &str) -> ::std::option::Option {
+ ::std::option::Option::Some(#name::#default_ident)
+ }
+ }
+ })
+}
+
+fn has_default_attr(variant: &syn::Variant) -> bool {
+ variant.attrs.iter().any(|a| a.path().is_ident("default"))
+}
+
+/// Converts a PascalCase identifier to lowercase kebab-case.
+///
+/// `"NewWorktree"` → `"new-worktree"`, `"Low"` → `"low"`,
+/// `"HTTPServer"` → `"httpserver"` (acronyms are not split — keep variant
+/// names descriptive to avoid this).
+fn to_kebab_case(ident: &str) -> String {
+ let mut out = String::with_capacity(ident.len() + 4);
+ for (i, ch) in ident.chars().enumerate() {
+ if ch.is_ascii_uppercase() {
+ if i != 0 {
+ out.push('-');
+ }
+ out.push(ch.to_ascii_lowercase());
+ } else {
+ out.push(ch);
+ }
+ }
+ out
+}
+
+/// Converts a PascalCase identifier to space-separated word form for display.
+///
+/// `"NewWorktree"` → `"New Worktree"`, `"Low"` → `"Low"`.
+fn to_space_separated(ident: &str) -> String {
+ let mut out = String::with_capacity(ident.len() + 4);
+ for (i, ch) in ident.chars().enumerate() {
+ if ch.is_ascii_uppercase() && i != 0 {
+ out.push(' ');
+ }
+ out.push(ch);
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn kebab_case() {
+ assert_eq!(to_kebab_case("Low"), "low");
+ assert_eq!(to_kebab_case("NewWorktree"), "new-worktree");
+ assert_eq!(to_kebab_case("A"), "a");
+ }
+
+ #[test]
+ fn space_separated() {
+ assert_eq!(to_space_separated("Low"), "Low");
+ assert_eq!(to_space_separated("NewWorktree"), "New Worktree");
+ }
+}
diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs
index 3215d761e0ace95d9010bbcb9ac7bb0b711c2c82..1e0f97bf6acf48a64b8464e521d1c19c421561ff 100644
--- a/crates/git/src/repository.rs
+++ b/crates/git/src/repository.rs
@@ -295,7 +295,7 @@ impl Worktree {
pub fn directory_name(&self, main_worktree_path: Option<&Path>) -> String {
if self.is_main {
- return "main".to_string();
+ return "main worktree".to_string();
}
let dir_name = self
diff --git a/crates/git_ui/src/clone.rs b/crates/git_ui/src/clone.rs
index a6767d33304d3f20b7a5e78340f62c89ebe3ae58..b3b8a9ed6fb302b1412523c8d5f18f710902576d 100644
--- a/crates/git_ui/src/clone.rs
+++ b/crates/git_ui/src/clone.rs
@@ -1,7 +1,7 @@
use gpui::{App, Context, WeakEntity, Window};
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
use std::sync::Arc;
-use ui::{Color, IconName, SharedString};
+use ui::{Color, Icon, IconName, IconSize, SharedString};
use util::ResultExt;
use workspace::{self, Workspace};
@@ -48,8 +48,12 @@ pub fn clone_and_open(
workspace
.update(cx, |workspace, cx| {
let toast = StatusToast::new(error.to_string(), cx, |this, _| {
- this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
- .dismiss_button(true)
+ this.icon(
+ Icon::new(IconName::XCircle)
+ .size(IconSize::Small)
+ .color(Color::Error),
+ )
+ .dismiss_button(true)
});
workspace.toggle_status_toast(toast, cx);
})
diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs
index 2548ac8a81ccc8026d3e020bd88026ef33e2bf72..4722887f5942ded70fa34bc1fd9441bb27590e46 100644
--- a/crates/git_ui/src/git_panel.rs
+++ b/crates/git_ui/src/git_panel.rs
@@ -50,7 +50,7 @@ use language_model::{
};
use menu;
use multi_buffer::ExcerptBoundaryInfo;
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
use panel::{PanelHeader, panel_button, panel_filled_button, panel_icon_button};
use project::{
Fs, Project, ProjectPath,
@@ -2686,7 +2686,7 @@ impl GitPanel {
}
let Some(ConfiguredModel { provider, model }) =
- LanguageModelRegistry::read_global(cx).commit_message_model()
+ LanguageModelRegistry::read_global(cx).commit_message_model(cx)
else {
return;
};
@@ -3864,9 +3864,17 @@ impl GitPanel {
let status_toast = StatusToast::new(message, cx, move |this, _cx| {
use remote_output::SuccessStyle::*;
match style {
- Toast => this.icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted)),
+ Toast => this.icon(
+ Icon::new(IconName::GitBranch)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ ),
ToastWithLog { output } => this
- .icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted))
+ .icon(
+ Icon::new(IconName::GitBranch)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
.action("View Log", move |window, cx| {
let output = output.clone();
let output =
@@ -3878,7 +3886,11 @@ impl GitPanel {
.ok();
}),
PushPrLink { text, link } => this
- .icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted))
+ .icon(
+ Icon::new(IconName::GitBranch)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
.action(text, move |_, cx| cx.open_url(&link)),
}
.dismiss_button(true)
@@ -4023,7 +4035,7 @@ impl GitPanel {
let model_registry = LanguageModelRegistry::read_global(cx);
let has_commit_model_configuration_error = model_registry
- .configuration_error(model_registry.commit_message_model(), cx)
+ .configuration_error(model_registry.commit_message_model(cx), cx)
.is_some();
let can_commit = self.can_commit();
@@ -6479,16 +6491,20 @@ pub(crate) fn show_error_toast(
workspace.update(cx, |workspace, cx| {
let workspace_weak = cx.weak_entity();
let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
- this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
- .action("View Log", move |window, cx| {
- let message = message.clone();
- let action = action.clone();
- workspace_weak
- .update(cx, move |workspace, cx| {
- open_output(action, workspace, &message, window, cx)
- })
- .ok();
- })
+ this.icon(
+ Icon::new(IconName::XCircle)
+ .size(IconSize::Small)
+ .color(Color::Error),
+ )
+ .action("View Log", move |window, cx| {
+ let message = message.clone();
+ let action = action.clone();
+ workspace_weak
+ .update(cx, move |workspace, cx| {
+ open_output(action, workspace, &message, window, cx)
+ })
+ .ok();
+ })
});
workspace.toggle_status_toast(toast, cx)
});
diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs
index 89d84cb7fb86cbf6359c8f3336a5ac9ae9fc1a9a..f9069d2920eedcd3ec75c1af781d90c818d49ba3 100644
--- a/crates/git_ui/src/worktree_picker.rs
+++ b/crates/git_ui/src/worktree_picker.rs
@@ -969,7 +969,7 @@ impl PickerDelegate for WorktreeListDelegate {
}
})),
)
- .when(!entry.is_new, |this| {
+ .when(!entry.is_new && !is_current, |this| {
let focus_handle = self.focus_handle.clone();
let open_in_new_window_button =
IconButton::new(("open-new-window", ix), IconName::ArrowUpRight)
@@ -1007,6 +1007,13 @@ impl PickerDelegate for WorktreeListDelegate {
let is_creating = selected_entry.is_some_and(|entry| entry.is_new);
let can_delete = selected_entry
.is_some_and(|entry| entry.can_delete(self.forbidden_deletion_path.as_ref()));
+ let is_current = selected_entry.is_some_and(|entry| {
+ !entry.is_new
+ && self
+ .current_worktree_path
+ .as_ref()
+ .is_some_and(|current| *current == entry.worktree.path)
+ });
let footer_container = h_flex()
.w_full()
@@ -1066,20 +1073,22 @@ impl PickerDelegate for WorktreeListDelegate {
}),
)
})
- .child(
- Button::new("open-in-new-window", "Open in New Window")
- .key_binding(
- KeyBinding::for_action_in(
- &menu::SecondaryConfirm,
- &focus_handle,
- cx,
+ .when(!is_current, |this| {
+ this.child(
+ Button::new("open-in-new-window", "Open in New Window")
+ .key_binding(
+ KeyBinding::for_action_in(
+ &menu::SecondaryConfirm,
+ &focus_handle,
+ cx,
+ )
+ .map(|kb| kb.size(rems_from_px(12.))),
)
- .map(|kb| kb.size(rems_from_px(12.))),
- )
- .on_click(|_, window, cx| {
- window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
- }),
- )
+ .on_click(|_, window, cx| {
+ window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
+ }),
+ )
+ })
.child(
Button::new("open-in-window", "Open")
.key_binding(
diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs
index 5028915c968d07994c7496728d7a71ad593fe502..b7485802a6dc16ea8e80487a9878350e14bf6a4e 100644
--- a/crates/gpui/src/platform.rs
+++ b/crates/gpui/src/platform.rs
@@ -1981,6 +1981,8 @@ pub enum ImageFormat {
Tiff,
/// .ico
Ico,
+ /// Netpbm image formats (.pbm, .ppm, .pgm).
+ Pnm,
}
impl ImageFormat {
@@ -1995,6 +1997,7 @@ impl ImageFormat {
ImageFormat::Bmp => "image/bmp",
ImageFormat::Tiff => "image/tiff",
ImageFormat::Ico => "image/ico",
+ ImageFormat::Pnm => "image/x-portable-anymap",
}
}
@@ -2121,6 +2124,7 @@ impl Image {
.render_single_frame(&self.bytes, 1.0)
.map_err(Into::into);
}
+ ImageFormat::Pnm => frames_for_image(&self.bytes, image::ImageFormat::Pnm)?,
};
Ok(Arc::new(RenderImage::new(frames)))
diff --git a/crates/gpui_linux/src/linux/x11/clipboard.rs b/crates/gpui_linux/src/linux/x11/clipboard.rs
index d2ea58b3f8c2acd0b6fbeba44eaf8b5a2e57531f..cbefea7650b0934f6fc8c46ffbe5802835ee792a 100644
--- a/crates/gpui_linux/src/linux/x11/clipboard.rs
+++ b/crates/gpui_linux/src/linux/x11/clipboard.rs
@@ -87,7 +87,7 @@ x11rb::atom_manager! {
BMP__MIME: ImageFormat::mime_type(ImageFormat::Bmp ).as_bytes(),
TIFF_MIME: ImageFormat::mime_type(ImageFormat::Tiff).as_bytes(),
ICO__MIME: ImageFormat::mime_type(ImageFormat::Ico ).as_bytes(),
-
+ PNM__MIME: ImageFormat::mime_type(ImageFormat::Pnm ).as_bytes(),
// This is just some random name for the property on our window, into which
// the clipboard owner writes the data we requested.
ARBOARD_CLIPBOARD,
@@ -1005,6 +1005,7 @@ impl Clipboard {
ImageFormat::Bmp => self.inner.atoms.BMP__MIME,
ImageFormat::Tiff => self.inner.atoms.TIFF_MIME,
ImageFormat::Ico => self.inner.atoms.ICO__MIME,
+ ImageFormat::Pnm => self.inner.atoms.PNM__MIME,
};
let data = vec![ClipboardData {
bytes: image.bytes,
diff --git a/crates/gpui_macos/src/pasteboard.rs b/crates/gpui_macos/src/pasteboard.rs
index d8b7f5627ddc44bea867132c91216b00729488d9..8362ab8f3b5c0ec76686d933699d5401b3c2c9fb 100644
--- a/crates/gpui_macos/src/pasteboard.rs
+++ b/crates/gpui_macos/src/pasteboard.rs
@@ -272,6 +272,7 @@ impl From for UTType {
ImageFormat::Bmp => Self::bmp(),
ImageFormat::Svg => Self::svg(),
ImageFormat::Ico => Self::ico(),
+ ImageFormat::Pnm => Self::pnm(),
}
}
}
@@ -320,6 +321,11 @@ impl UTType {
Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
}
+ pub fn pnm() -> Self {
+ //https://en.wikipedia.org/w/index.php?title=Netpbm&oldid=1336679433 under Uniform Type Identifier
+ Self(unsafe { ns_string("public.pbm") })
+ }
+
fn inner(&self) -> *const Object {
self.0
}
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index 3c6e09cf60451ea36d9ab44471137d96d76987da..20d7b609d8de07c4de4c489eac90b312fbf9c210 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -66,6 +66,7 @@ pub enum IconName {
ChevronUpDown,
Circle,
CircleHelp,
+ Clock,
Close,
CloudDownload,
Code,
@@ -242,7 +243,6 @@ pub enum IconName {
ThinkingModeOff,
Thread,
ThreadFromSummary,
- ThreadImport,
ThreadsSidebarLeftClosed,
ThreadsSidebarLeftOpen,
ThreadsSidebarRightClosed,
diff --git a/crates/json_schema_store/Cargo.toml b/crates/json_schema_store/Cargo.toml
index e62c976abb934972b2c81981b13ce3021bef00d0..8180ffd7917718fa909b0aecf7afe3e50b277333 100644
--- a/crates/json_schema_store/Cargo.toml
+++ b/crates/json_schema_store/Cargo.toml
@@ -14,12 +14,17 @@ path = "src/json_schema_store.rs"
[features]
default = []
+[dev-dependencies]
+gpui = { workspace = true, features = ["test-support"] }
+settings = { workspace = true, features = ["test-support"] }
+
[dependencies]
anyhow.workspace = true
collections.workspace = true
dap.workspace = true
parking_lot.workspace = true
extension.workspace = true
+feature_flags.workspace = true
gpui.workspace = true
language.workspace = true
paths.workspace = true
diff --git a/crates/json_schema_store/src/json_schema_store.rs b/crates/json_schema_store/src/json_schema_store.rs
index c13f42f9bb7d92b7c136815f720abfe6ec6faac3..629042f745dbee46b326778ad8c002e7e2464bd4 100644
--- a/crates/json_schema_store/src/json_schema_store.rs
+++ b/crates/json_schema_store/src/json_schema_store.rs
@@ -352,13 +352,16 @@ async fn resolve_dynamic_schema(
let icon_theme_names = icon_theme_names.as_slice();
let theme_names = theme_names.as_slice();
- settings::SettingsStore::json_schema(&settings::SettingsJsonSchemaParams {
- language_names,
- font_names,
- theme_names,
- icon_theme_names,
- lsp_adapter_names: &lsp_adapter_names,
- })
+ let mut schema =
+ settings::SettingsStore::json_schema(&settings::SettingsJsonSchemaParams {
+ language_names,
+ font_names,
+ theme_names,
+ icon_theme_names,
+ lsp_adapter_names: &lsp_adapter_names,
+ });
+ inject_feature_flags_schema(&mut schema);
+ schema
})
}
"project_settings" => {
@@ -374,16 +377,19 @@ async fn resolve_dynamic_schema(
.map(|name| name.to_string())
.collect::>();
- settings::SettingsStore::project_json_schema(&settings::SettingsJsonSchemaParams {
- language_names,
- lsp_adapter_names: &lsp_adapter_names,
- // These are not allowed in project-specific settings but
- // they're still fields required by the
- // `SettingsJsonSchemaParams` struct.
- font_names: &[],
- theme_names: &[],
- icon_theme_names: &[],
- })
+ let mut schema =
+ settings::SettingsStore::project_json_schema(&settings::SettingsJsonSchemaParams {
+ language_names,
+ lsp_adapter_names: &lsp_adapter_names,
+ // These are not allowed in project-specific settings but
+ // they're still fields required by the
+ // `SettingsJsonSchemaParams` struct.
+ font_names: &[],
+ theme_names: &[],
+ icon_theme_names: &[],
+ });
+ inject_feature_flags_schema(&mut schema);
+ schema
}
"debug_tasks" => {
let adapter_schemas = cx.read_global::(|dap_registry, _| {
@@ -513,6 +519,21 @@ pub fn all_schema_file_associations(
file_associations
}
+/// Swaps the placeholder [`settings::FeatureFlagsMap`] subschema produced by
+/// schemars for an enriched one that lists each known flag's variants. The
+/// placeholder is registered in the `settings_content` crate so the
+/// `settings` crate doesn't need a reverse dependency on `feature_flags`.
+fn inject_feature_flags_schema(schema: &mut serde_json::Value) {
+ use schemars::JsonSchema;
+
+ let Some(defs) = schema.get_mut("$defs").and_then(|d| d.as_object_mut()) else {
+ return;
+ };
+ let schema_name = settings::FeatureFlagsMap::schema_name();
+ let enriched = feature_flags::generate_feature_flags_schema().to_value();
+ defs.insert(schema_name.into_owned(), enriched);
+}
+
fn generate_jsonc_schema() -> serde_json::Value {
let generator = schemars::generate::SchemaSettings::draft2019_09()
.with_transform(DefaultDenyUnknownFields)
diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs
index c4833620cf4ec0a6dc965aa9e23c2690a44773fd..70d6f326a5fd0a17c306c8a8af4645d8e234f837 100644
--- a/crates/keymap_editor/src/keymap_editor.rs
+++ b/crates/keymap_editor/src/keymap_editor.rs
@@ -25,7 +25,7 @@ use gpui::{
};
use language::{Language, LanguageConfig, ToOffset as _};
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
use project::{CompletionDisplayOptions, Project};
use settings::{
BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets, infer_json_indent_size,
@@ -2883,8 +2883,12 @@ impl KeybindingEditorModal {
format!("Saved edits to the {} action.", humanized_action_name),
cx,
move |this, _cx| {
- this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
- .dismiss_button(true)
+ this.icon(
+ Icon::new(IconName::Check)
+ .size(IconSize::Small)
+ .color(Color::Success),
+ )
+ .dismiss_button(true)
// .action("Undo", f) todo: wire the undo functionality
},
);
diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs
index 680078808ab33cc2a90caead8b304326beccf11b..219d9f4b39e8facbefc56c479dad8acd0b5c53c5 100644
--- a/crates/language_model/src/registry.rs
+++ b/crates/language_model/src/registry.rs
@@ -6,7 +6,6 @@ use collections::{BTreeMap, HashSet};
use gpui::{App, Context, Entity, EventEmitter, Global, prelude::*};
use std::{str::FromStr, sync::Arc};
use thiserror::Error;
-use util::maybe;
/// Function type for checking if a built-in provider should be hidden.
/// Returns Some(extension_id) if the provider should be hidden when that extension is installed.
@@ -46,7 +45,9 @@ impl std::fmt::Debug for ConfigurationError {
#[derive(Default)]
pub struct LanguageModelRegistry {
default_model: Option,
- default_fast_model: Option,
+ /// This model is automatically configured by a user's environment after
+ /// authenticating all providers. It's only used when `default_model` is not set.
+ available_fallback_model: Option,
inline_assistant_model: Option,
commit_message_model: Option,
thread_summary_model: Option,
@@ -349,22 +350,29 @@ impl LanguageModelRegistry {
}
pub fn set_default_model(&mut self, model: Option, cx: &mut Context) {
- match (self.default_model.as_ref(), model.as_ref()) {
+ match (self.default_model(), model.as_ref()) {
(Some(old), Some(new)) if old.is_same_as(new) => {}
(None, None) => {}
_ => cx.emit(Event::DefaultModelChanged),
}
- self.default_fast_model = maybe!({
- let provider = &model.as_ref()?.provider;
- let fast_model = provider.default_fast_model(cx)?;
- Some(ConfiguredModel {
- provider: provider.clone(),
- model: fast_model,
- })
- });
self.default_model = model;
}
+ pub fn set_environment_fallback_model(
+ &mut self,
+ model: Option,
+ cx: &mut Context,
+ ) {
+ if self.default_model.is_none() {
+ match (self.available_fallback_model.as_ref(), model.as_ref()) {
+ (Some(old), Some(new)) if old.is_same_as(new) => {}
+ (None, None) => {}
+ _ => cx.emit(Event::DefaultModelChanged),
+ }
+ }
+ self.available_fallback_model = model;
+ }
+
pub fn set_inline_assistant_model(
&mut self,
model: Option,
@@ -410,7 +418,18 @@ impl LanguageModelRegistry {
return None;
}
- self.default_model.clone()
+ self.default_model
+ .clone()
+ .or_else(|| self.available_fallback_model.clone())
+ }
+
+ pub fn default_fast_model(&self, cx: &App) -> Option {
+ let configured = self.default_model()?;
+ let fast_model = configured.provider.default_fast_model(cx)?;
+ Some(ConfiguredModel {
+ provider: configured.provider,
+ model: fast_model,
+ })
}
pub fn inline_assistant_model(&self) -> Option {
@@ -424,7 +443,7 @@ impl LanguageModelRegistry {
.or_else(|| self.default_model.clone())
}
- pub fn commit_message_model(&self) -> Option {
+ pub fn commit_message_model(&self, cx: &App) -> Option {
#[cfg(debug_assertions)]
if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() {
return None;
@@ -432,11 +451,11 @@ impl LanguageModelRegistry {
self.commit_message_model
.clone()
- .or_else(|| self.default_fast_model.clone())
- .or_else(|| self.default_model.clone())
+ .or_else(|| self.default_fast_model(cx))
+ .or_else(|| self.default_model())
}
- pub fn thread_summary_model(&self) -> Option {
+ pub fn thread_summary_model(&self, cx: &App) -> Option {
#[cfg(debug_assertions)]
if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() {
return None;
@@ -444,8 +463,8 @@ impl LanguageModelRegistry {
self.thread_summary_model
.clone()
- .or_else(|| self.default_fast_model.clone())
- .or_else(|| self.default_model.clone())
+ .or_else(|| self.default_fast_model(cx))
+ .or_else(|| self.default_model())
}
/// The models to use for inline assists. Returns the union of the active
@@ -576,6 +595,35 @@ mod tests {
assert!(!registry_read.should_hide_provider(&LanguageModelProviderId("unknown".into())));
}
+ #[gpui::test]
+ async fn test_configure_environment_fallback_model(cx: &mut gpui::TestAppContext) {
+ let registry = cx.new(|_| LanguageModelRegistry::default());
+
+ let provider = Arc::new(FakeLanguageModelProvider::default());
+ registry.update(cx, |registry, cx| {
+ registry.register_provider(provider.clone(), cx);
+ });
+
+ cx.update(|cx| provider.authenticate(cx)).await.unwrap();
+
+ registry.update(cx, |registry, cx| {
+ let provider = registry.provider(&provider.id()).unwrap();
+ let model = provider.default_model(cx).unwrap();
+
+ registry.set_environment_fallback_model(
+ Some(ConfiguredModel {
+ provider: provider.clone(),
+ model: model.clone(),
+ }),
+ cx,
+ );
+
+ let default_model = registry.default_model().unwrap();
+ assert_eq!(default_model.model.id(), model.id());
+ assert_eq!(default_model.provider.id(), provider.id());
+ });
+ }
+
#[gpui::test]
fn test_sync_installed_llm_extensions(cx: &mut App) {
let registry = cx.new(|_| LanguageModelRegistry::default());
diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs
index 3154db91a43d1381f5b3f122a724be249adeb79b..bd29dbe08dbd16af25be4bd55b44067f47fa2a8a 100644
--- a/crates/language_models/src/language_models.rs
+++ b/crates/language_models/src/language_models.rs
@@ -5,7 +5,9 @@ use client::{Client, UserStore};
use collections::HashSet;
use credentials_provider::CredentialsProvider;
use gpui::{App, Context, Entity};
-use language_model::{LanguageModelProviderId, LanguageModelRegistry};
+use language_model::{
+ ConfiguredModel, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID,
+};
use provider::deepseek::DeepSeekLanguageModelProvider;
pub mod extension;
@@ -116,6 +118,20 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) {
cx,
);
});
+
+ cx.subscribe(
+ ®istry,
+ |_registry, event: &language_model::Event, cx| match event {
+ language_model::Event::ProviderStateChanged(_)
+ | language_model::Event::AddedProvider(_)
+ | language_model::Event::RemovedProvider(_) => {
+ update_environment_fallback_model(cx);
+ }
+ _ => {}
+ },
+ )
+ .detach();
+
let registry = registry.downgrade();
cx.observe_global::(move |cx| {
let Some(registry) = registry.upgrade() else {
@@ -143,6 +159,50 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) {
.detach();
}
+/// Recomputes and sets the [`LanguageModelRegistry`]'s environment fallback
+/// model based on currently authenticated providers.
+///
+/// Prefers the Zed cloud provider so that, once the user is signed in, we
+/// always pick a Zed-hosted model over models from other authenticated
+/// providers in the environment. If the Zed cloud provider is authenticated
+/// but hasn't finished loading its models yet, we don't fall back to another
+/// provider to avoid flickering between providers during sign in.
+pub fn update_environment_fallback_model(cx: &mut App) {
+ let registry = LanguageModelRegistry::global(cx);
+ let fallback_model = {
+ let registry = registry.read(cx);
+ let cloud_provider = registry.provider(&ZED_CLOUD_PROVIDER_ID);
+ if cloud_provider
+ .as_ref()
+ .is_some_and(|provider| provider.is_authenticated(cx))
+ {
+ cloud_provider.and_then(|provider| {
+ let model = provider
+ .default_model(cx)
+ .or_else(|| provider.recommended_models(cx).first().cloned())?;
+ Some(ConfiguredModel { provider, model })
+ })
+ } else {
+ registry
+ .providers()
+ .iter()
+ .filter(|provider| provider.is_authenticated(cx))
+ .find_map(|provider| {
+ let model = provider
+ .default_model(cx)
+ .or_else(|| provider.recommended_models(cx).first().cloned())?;
+ Some(ConfiguredModel {
+ provider: provider.clone(),
+ model,
+ })
+ })
+ }
+ };
+ registry.update(cx, |registry, cx| {
+ registry.set_environment_fallback_model(fallback_model, cx);
+ });
+}
+
fn register_openai_compatible_providers(
registry: &mut LanguageModelRegistry,
old: &HashSet>,
diff --git a/crates/notifications/src/status_toast.rs b/crates/notifications/src/status_toast.rs
index 8c177bfe9ca66a81af2b4441b6c4703e9d871395..d9ffcddc6ffe6539723e04d37749b7c7f03b1ca1 100644
--- a/crates/notifications/src/status_toast.rs
+++ b/crates/notifications/src/status_toast.rs
@@ -5,41 +5,13 @@ use ui::{Tooltip, prelude::*};
use workspace::{ToastAction, ToastView};
use zed_actions::toast;
-#[derive(Clone, Copy)]
-pub struct ToastIcon {
- icon: IconName,
- color: Color,
-}
-
-impl ToastIcon {
- pub fn new(icon: IconName) -> Self {
- Self {
- icon,
- color: Color::default(),
- }
- }
-
- pub fn color(mut self, color: Color) -> Self {
- self.color = color;
- self
- }
-}
-
-impl From for ToastIcon {
- fn from(icon: IconName) -> Self {
- Self {
- icon,
- color: Color::default(),
- }
- }
-}
-
#[derive(RegisterComponent)]
pub struct StatusToast {
- icon: Option,
+ icon: Option,
text: SharedString,
action: Option,
show_dismiss: bool,
+ auto_dismiss: bool,
this_handle: Entity,
focus_handle: FocusHandle,
}
@@ -59,6 +31,7 @@ impl StatusToast {
icon: None,
action: None,
show_dismiss: false,
+ auto_dismiss: true,
this_handle: cx.entity(),
focus_handle,
},
@@ -67,11 +40,16 @@ impl StatusToast {
})
}
- pub fn icon(mut self, icon: ToastIcon) -> Self {
+ pub fn icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon);
self
}
+ pub fn auto_dismiss(mut self, auto_dismiss: bool) -> Self {
+ self.auto_dismiss = auto_dismiss;
+ self
+ }
+
pub fn action(
mut self,
label: impl Into,
@@ -116,9 +94,7 @@ impl Render for StatusToast {
.flex_none()
.bg(cx.theme().colors().surface_background)
.shadow_lg()
- .when_some(self.icon.as_ref(), |this, icon| {
- this.child(Icon::new(icon.icon).color(icon.color))
- })
+ .when_some(self.icon.clone(), |this, icon| this.child(icon))
.child(Label::new(self.text.clone()).color(Color::Default))
.when_some(self.action.as_ref(), |this, action| {
this.child(
@@ -155,6 +131,10 @@ impl ToastView for StatusToast {
fn action(&self) -> Option {
self.action.clone()
}
+
+ fn auto_dismiss(&self) -> bool {
+ self.auto_dismiss
+ }
}
impl Focusable for StatusToast {
@@ -183,33 +163,55 @@ impl Component for StatusToast {
let icon_example = StatusToast::new(
"Nathan Sobo accepted your contact request",
cx,
- |this, _| this.icon(ToastIcon::new(IconName::Check).color(Color::Muted)),
+ |this, _| {
+ this.icon(
+ Icon::new(IconName::Check)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ },
);
let success_example = StatusToast::new("Pushed 4 changes to `zed/main`", cx, |this, _| {
- this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
+ this.icon(
+ Icon::new(IconName::Check)
+ .size(IconSize::Small)
+ .color(Color::Success),
+ )
});
let error_example = StatusToast::new(
"git push: Couldn't find remote origin `iamnbutler/zed`",
cx,
|this, _cx| {
- this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
- .action("More Info", |_, _| {})
+ this.icon(
+ Icon::new(IconName::XCircle)
+ .size(IconSize::Small)
+ .color(Color::Error),
+ )
+ .action("More Info", |_, _| {})
},
);
let warning_example = StatusToast::new("You have outdated settings", cx, |this, _cx| {
- this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
- .action("More Info", |_, _| {})
+ this.icon(
+ Icon::new(IconName::Warning)
+ .size(IconSize::Small)
+ .color(Color::Warning),
+ )
+ .action("More Info", |_, _| {})
});
let pr_example =
StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
- this.icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted))
- .action("Open Pull Request", |_, cx| {
- cx.open_url("https://github.com/")
- })
+ this.icon(
+ Icon::new(IconName::GitBranch)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .action("Open Pull Request", |_, cx| {
+ cx.open_url("https://github.com/")
+ })
});
Some(
diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs
index caa1a5458f66f77a46665627731f720a47a0cdbd..4a6a3c821cdb3ae5fc03b2711a39176bd3d432d9 100644
--- a/crates/onboarding/src/onboarding.rs
+++ b/crates/onboarding/src/onboarding.rs
@@ -7,7 +7,7 @@ use gpui::{
FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, ScrollHandle, SharedString,
Subscription, Task, WeakEntity, Window, actions,
};
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
use schemars::JsonSchema;
use serde::Deserialize;
use settings::{SettingsStore, VsCodeSettingsSource};
@@ -495,8 +495,12 @@ pub async fn handle_import_vscode_settings(
format!("Your {} settings were successfully imported.", source),
cx,
|this, _| {
- this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
- .dismiss_button(true)
+ this.icon(
+ Icon::new(IconName::Check)
+ .size(IconSize::Small)
+ .color(Color::Success),
+ )
+ .dismiss_button(true)
},
);
SettingsImportState::update(cx, |state, _| match source {
@@ -514,11 +518,15 @@ pub async fn handle_import_vscode_settings(
"Failed to import settings. See log for details",
cx,
|this, _| {
- this.icon(ToastIcon::new(IconName::Close).color(Color::Error))
- .action("Open Log", |window, cx| {
- window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
- })
- .dismiss_button(true)
+ this.icon(
+ Icon::new(IconName::Close)
+ .size(IconSize::Small)
+ .color(Color::Error),
+ )
+ .action("Open Log", |window, cx| {
+ window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
+ })
+ .dismiss_button(true)
},
);
workspace.toggle_status_toast(error_toast, cx);
diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs
index 0ba9787d2e4144cb529756b15fc05ff72dab83c8..0b6dcfa0078588a55067f18757ca575b4aad45d2 100644
--- a/crates/project/src/image_store.rs
+++ b/crates/project/src/image_store.rs
@@ -902,6 +902,7 @@ fn create_gpui_image(content: Vec) -> anyhow::Result> {
image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
image::ImageFormat::Ico => gpui::ImageFormat::Ico,
+ image::ImageFormat::Pnm => gpui::ImageFormat::Pnm,
format => anyhow::bail!("Image format {format:?} not supported"),
},
content,
diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs
index ab5c46752c6cd92e35a2eb283a50b38e76b649d7..768ab9afb91b4ec9c72962eb4f60f2d90519e791 100644
--- a/crates/project/src/worktree_store.rs
+++ b/crates/project/src/worktree_store.rs
@@ -1383,12 +1383,6 @@ impl WorktreeStore {
pub fn paths(&self, cx: &App) -> WorktreePaths {
let (mains, folders): (Vec, Vec) = self
.visible_worktrees(cx)
- .filter(|worktree| {
- let worktree = worktree.read(cx);
- // Remote worktrees that haven't received their first update
- // don't have enough data to contribute yet.
- !worktree.is_remote() || worktree.root_entry().is_some()
- })
.map(|worktree| {
let snapshot = worktree.read(cx).snapshot();
let folder_path = snapshot.abs_path().to_path_buf();
diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs
index b409962d9fd20621ad4c1153ab723cf9e08d85a0..3a5047c0d7a6d40968ca7b5d10f65e317dbc92a8 100644
--- a/crates/project_panel/src/project_panel.rs
+++ b/crates/project_panel/src/project_panel.rs
@@ -31,7 +31,7 @@ use gpui::{
};
use language::DiagnosticSeverity;
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
use project::{
Entry, EntryKind, Fs, GitEntry, GitEntryRef, GitTraversal, Project, ProjectEntryId,
ProjectPath, Worktree, WorktreeId,
@@ -2275,8 +2275,12 @@ impl ProjectPanel {
.update(cx, |panel, cx| {
let message = format!("Failed to restore {}: {}", file_name, e);
let toast = StatusToast::new(message, cx, |this, _| {
- this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
- .dismiss_button(true)
+ this.icon(
+ Icon::new(IconName::XCircle)
+ .size(IconSize::Small)
+ .color(Color::Error),
+ )
+ .dismiss_button(true)
});
panel
.workspace
diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs
index 0f80b20050b678590c9cf7fd20d2e0a2cfbd2428..ce0b6f598971ecdb07bdf763a359830dd334be1e 100644
--- a/crates/repl/src/notebook/notebook_ui.rs
+++ b/crates/repl/src/notebook/notebook_ui.rs
@@ -68,8 +68,8 @@ pub fn init(cx: &mut App) {
}
cx.observe_flag::({
- move |is_enabled, cx| {
- if is_enabled {
+ move |flag, cx| {
+ if *flag {
workspace::register_project_item::(cx);
} else {
// todo: there is no way to unregister a project item, so if the feature flag
diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs
index c83e56577373aa9834f76b3c32488a069844d249..0913f1d0b9cb82333863f87a0cdf2174640a4eee 100644
--- a/crates/settings/src/vscode_import.rs
+++ b/crates/settings/src/vscode_import.rs
@@ -220,6 +220,7 @@ impl VsCodeSettings {
workspace: self.workspace_settings_content(),
which_key: None,
modeline_lines: None,
+ feature_flags: None,
}
}
diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs
index 76891185c42ee36324c1cc160edfb27d63ecc0d6..12756c9bad5d9bf15ca5d02a977d816e80822d8b 100644
--- a/crates/settings_content/src/agent.rs
+++ b/crates/settings_content/src/agent.rs
@@ -128,6 +128,12 @@ pub struct AgentSettingsContent {
/// Default: 320
#[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
pub default_height: Option,
+ /// Whether to limit the content width in the agent panel. When enabled,
+ /// content will be constrained to `max_content_width` and centered when
+ /// the panel is wider than that value, for optimal readability.
+ ///
+ /// Default: true
+ pub limit_content_width: Option,
/// Maximum content width in pixels for the agent panel. Content will be
/// centered when the panel is wider than this value.
///
@@ -269,13 +275,34 @@ impl AgentSettingsContent {
}
pub fn add_favorite_model(&mut self, model: LanguageModelSelection) {
- if !self.favorite_models.contains(&model) {
+ // Note: this is intentional to not compare using `PartialEq`here.
+ // Full equality would treat entries that differ just in thinking/effort/speed
+ // as distinct and silently produce duplicates.
+ if !self
+ .favorite_models
+ .iter()
+ .any(|m| m.provider == model.provider && m.model == model.model)
+ {
self.favorite_models.push(model);
}
}
pub fn remove_favorite_model(&mut self, model: &LanguageModelSelection) {
- self.favorite_models.retain(|m| m != model);
+ self.favorite_models
+ .retain(|m| !(m.provider == model.provider && m.model == model.model));
+ }
+
+ pub fn update_favorite_model(&mut self, provider: &str, model: &str, f: F)
+ where
+ F: FnOnce(&mut LanguageModelSelection),
+ {
+ if let Some(entry) = self
+ .favorite_models
+ .iter_mut()
+ .find(|m| m.provider.0 == provider && m.model == model)
+ {
+ f(entry);
+ }
}
pub fn set_tool_default_permission(&mut self, tool_id: &str, mode: ToolPermissionMode) {
diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs
index 34072825c9c8c0f118f56220c51a54d2c5f6b832..d949b81e2329daaaa7e5ad040898b5a732a8c52b 100644
--- a/crates/settings_content/src/settings_content.rs
+++ b/crates/settings_content/src/settings_content.rs
@@ -211,6 +211,45 @@ pub struct SettingsContent {
///
/// Default: 5
pub modeline_lines: Option,
+
+ /// Local overrides for feature flags, keyed by flag name.
+ pub feature_flags: Option,
+}
+
+#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, MergeFrom)]
+#[serde(transparent)]
+pub struct FeatureFlagsMap(pub HashMap);
+
+// A manual `JsonSchema` impl keeps this type's schema registered under a
+// unique name. The derived impl on a `#[serde(transparent)]` newtype around
+// `HashMap` would inline to the map's own schema name (`Map_of_string`),
+// which is shared with every other `HashMap` setting field in
+// `SettingsContent`. A named placeholder lets `json_schema_store` find and
+// replace just this field's schema at runtime without clobbering the others.
+impl JsonSchema for FeatureFlagsMap {
+ fn schema_name() -> std::borrow::Cow<'static, str> {
+ "FeatureFlagsMap".into()
+ }
+
+ fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
+ schemars::json_schema!({
+ "type": "object",
+ "additionalProperties": { "type": "string" }
+ })
+ }
+}
+
+impl std::ops::Deref for FeatureFlagsMap {
+ type Target = HashMap;
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl std::ops::DerefMut for FeatureFlagsMap {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.0
+ }
}
impl SettingsContent {
diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs
index 0f5679b85f80c418ecc677349689878e7322597a..cf53fa598a281db15509459508effe8109da219c 100644
--- a/crates/settings_ui/src/page_data.rs
+++ b/crates/settings_ui/src/page_data.rs
@@ -62,7 +62,7 @@ macro_rules! concat_sections {
}
pub(crate) fn settings_data(cx: &App) -> Vec {
- vec![
+ let mut pages = vec![
general_page(cx),
appearance_page(),
keymap_page(),
@@ -77,7 +77,32 @@ pub(crate) fn settings_data(cx: &App) -> Vec {
collaboration_page(),
ai_page(cx),
network_page(),
- ]
+ ];
+
+ use feature_flags::FeatureFlagAppExt as _;
+ if cx.is_staff() || cfg!(debug_assertions) {
+ pages.push(developer_page());
+ }
+
+ pages
+}
+
+fn developer_page() -> SettingsPage {
+ SettingsPage {
+ title: "Developer",
+ items: Box::new([
+ SettingsPageItem::SectionHeader("Feature Flags"),
+ SettingsPageItem::SubPageLink(SubPageLink {
+ title: "Feature Flags".into(),
+ r#type: Default::default(),
+ description: None,
+ json_path: Some("feature_flags"),
+ in_json: true,
+ files: USER,
+ render: crate::pages::render_feature_flags_page,
+ }),
+ ]),
+ }
}
fn general_page(cx: &App) -> SettingsPage {
@@ -5811,23 +5836,58 @@ fn panels_page() -> SettingsPage {
metadata: None,
files: USER,
}),
- SettingsPageItem::SettingItem(SettingItem {
- title: "Agent Panel Max Content Width",
- description: "Maximum content width in pixels. Content will be centered when the panel is wider than this value.",
- field: Box::new(SettingField {
- json_path: Some("agent.max_content_width"),
- pick: |settings_content| {
- settings_content.agent.as_ref()?.max_content_width.as_ref()
- },
- write: |settings_content, value| {
- settings_content
- .agent
- .get_or_insert_default()
- .max_content_width = value;
- },
- }),
- metadata: None,
- files: USER,
+ SettingsPageItem::DynamicItem(DynamicItem {
+ discriminant: SettingItem {
+ files: USER,
+ title: "Limit Content Width",
+ description: "Whether to constrain the agent panel content to a maximum width, centering it when the panel is wider, for optimal readability.",
+ field: Box::new(SettingField:: {
+ json_path: Some("agent.limit_content_width"),
+ pick: |settings_content| {
+ settings_content
+ .agent
+ .as_ref()?
+ .limit_content_width
+ .as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content
+ .agent
+ .get_or_insert_default()
+ .limit_content_width = value;
+ },
+ }),
+ metadata: None,
+ },
+ pick_discriminant: |settings_content| {
+ let enabled = settings_content
+ .agent
+ .as_ref()?
+ .limit_content_width
+ .unwrap_or(true);
+ Some(if enabled { 1 } else { 0 })
+ },
+ fields: vec![
+ vec![],
+ vec![SettingItem {
+ files: USER,
+ title: "Max Content Width",
+ description: "Maximum content width in pixels. Content will be centered when the panel is wider than this value.",
+ field: Box::new(SettingField {
+ json_path: Some("agent.max_content_width"),
+ pick: |settings_content| {
+ settings_content.agent.as_ref()?.max_content_width.as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content
+ .agent
+ .get_or_insert_default()
+ .max_content_width = value;
+ },
+ }),
+ metadata: None,
+ }],
+ ],
}),
]
}
diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs
index a54f52b09cae65268b95e16a2131ef3c9aa48ae3..401534b66059e61e52406b85f509ae9c935eeab2 100644
--- a/crates/settings_ui/src/pages.rs
+++ b/crates/settings_ui/src/pages.rs
@@ -1,6 +1,7 @@
mod audio_input_output_setup;
mod audio_test_window;
mod edit_prediction_provider_setup;
+mod feature_flags;
mod tool_permissions_setup;
pub(crate) use audio_input_output_setup::{
@@ -8,6 +9,7 @@ pub(crate) use audio_input_output_setup::{
};
pub(crate) use audio_test_window::open_audio_test_window;
pub(crate) use edit_prediction_provider_setup::render_edit_prediction_setup_page;
+pub(crate) use feature_flags::render_feature_flags_page;
pub(crate) use tool_permissions_setup::render_tool_permissions_setup_page;
pub use tool_permissions_setup::{
diff --git a/crates/settings_ui/src/pages/feature_flags.rs b/crates/settings_ui/src/pages/feature_flags.rs
new file mode 100644
index 0000000000000000000000000000000000000000..462f5513b25f9516e0200928a93b74c75dbb4b6e
--- /dev/null
+++ b/crates/settings_ui/src/pages/feature_flags.rs
@@ -0,0 +1,132 @@
+use feature_flags::{FeatureFlagDescriptor, FeatureFlagStore, FeatureFlagVariant};
+use fs::Fs;
+use gpui::{ScrollHandle, prelude::*};
+use ui::{Checkbox, ToggleState, prelude::*};
+
+use crate::SettingsWindow;
+
+pub(crate) fn render_feature_flags_page(
+ _settings_window: &SettingsWindow,
+ scroll_handle: &ScrollHandle,
+ _window: &mut Window,
+ cx: &mut Context,
+) -> AnyElement {
+ // Sort by flag name so the list is stable between renders even though
+ // `inventory::iter` order depends on link order.
+ let mut descriptors: Vec<&'static FeatureFlagDescriptor> =
+ FeatureFlagStore::known_flags().collect();
+ descriptors.sort_by_key(|descriptor| descriptor.name);
+
+ v_flex()
+ .id("feature-flags-page")
+ .min_w_0()
+ .size_full()
+ .pt_2p5()
+ .px_8()
+ .pb_16()
+ .gap_4()
+ .overflow_y_scroll()
+ .track_scroll(scroll_handle)
+ .children(
+ descriptors
+ .into_iter()
+ .map(|descriptor| render_flag_row(descriptor, cx)),
+ )
+ .into_any_element()
+}
+
+fn render_flag_row(
+ descriptor: &'static FeatureFlagDescriptor,
+ cx: &mut Context,
+) -> AnyElement {
+ let forced_on = FeatureFlagStore::is_forced_on(descriptor);
+ let resolved = cx.global::().resolved_key(descriptor, cx);
+ let has_override = FeatureFlagStore::override_for(descriptor.name, cx).is_some();
+
+ let header =
+ h_flex()
+ .justify_between()
+ .items_center()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(Label::new(descriptor.name).size(LabelSize::Default).color(
+ if forced_on {
+ Color::Muted
+ } else {
+ Color::Default
+ },
+ ))
+ .when(forced_on, |this| {
+ this.child(
+ Label::new("enabled for all")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ }),
+ )
+ .when(has_override && !forced_on, |this| {
+ let name = descriptor.name;
+ this.child(
+ Button::new(SharedString::from(format!("reset-{}", name)), "Reset")
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(move |_, _, _, cx| {
+ FeatureFlagStore::clear_override(name, ::global(cx), cx);
+ })),
+ )
+ });
+
+ v_flex()
+ .id(SharedString::from(format!("flag-row-{}", descriptor.name)))
+ .gap_1()
+ .child(header)
+ .child(render_flag_variants(descriptor, resolved, forced_on, cx))
+ .into_any_element()
+}
+
+fn render_flag_variants(
+ descriptor: &'static FeatureFlagDescriptor,
+ resolved: &'static str,
+ forced_on: bool,
+ cx: &mut Context,
+) -> impl IntoElement {
+ let variants: Vec = (descriptor.variants)();
+
+ let row_items = variants.into_iter().map({
+ let name = descriptor.name;
+ move |variant| {
+ let key = variant.override_key;
+ let label = variant.label;
+ let selected = resolved == key;
+ let state = if selected {
+ ToggleState::Selected
+ } else {
+ ToggleState::Unselected
+ };
+ let checkbox_id = SharedString::from(format!("{}-{}", name, key));
+ let disabled = forced_on;
+ let mut checkbox = Checkbox::new(ElementId::from(checkbox_id), state)
+ .label(label)
+ .disabled(disabled);
+ if !disabled {
+ checkbox =
+ checkbox.on_click(cx.listener(move |_, new_state: &ToggleState, _, cx| {
+ // Clicking an already-selected option is a no-op rather than a
+ // "deselect" — there's no valid "nothing selected" state.
+ if *new_state == ToggleState::Unselected {
+ return;
+ }
+ FeatureFlagStore::set_override(
+ name,
+ key.to_string(),
+ ::global(cx),
+ cx,
+ );
+ }));
+ }
+ checkbox.into_any_element()
+ }
+ });
+
+ h_flex().gap_4().flex_wrap().children(row_items)
+}
diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs
index 63813b2783b6bfebed3599fece426a8f3ee23141..bd503dcb28061749ca60ebf2af5c3f92eb839305 100644
--- a/crates/settings_ui/src/settings_ui.rs
+++ b/crates/settings_ui/src/settings_ui.rs
@@ -1521,6 +1521,17 @@ impl SettingsWindow {
})
.detach();
+ use feature_flags::FeatureFlagAppExt as _;
+ let mut last_is_staff = cx.is_staff();
+ cx.observe_global_in::(window, move |this, window, cx| {
+ let is_staff = cx.is_staff();
+ if is_staff != last_is_staff {
+ last_is_staff = is_staff;
+ this.rebuild_pages(window, cx);
+ }
+ })
+ .detach();
+
cx.on_window_closed(|cx, _window_id| {
if let Some(existing_window) = cx
.windows()
@@ -2143,6 +2154,15 @@ impl SettingsWindow {
cx.notify();
}
+ fn rebuild_pages(&mut self, window: &mut Window, cx: &mut Context) {
+ self.pages.clear();
+ self.navbar_entries.clear();
+ self.navbar_focus_subscriptions.clear();
+ self.content_handles.clear();
+ self.build_ui(window, cx);
+ self.build_search_index();
+ }
+
#[track_caller]
fn fetch_files(&mut self, window: &mut Window, cx: &mut Context) {
self.worktree_root_dirs.clear();
diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml
index 4ac1257d94a417c56bbfd90366a01c7f64bb003a..c0c0b26e1dfe2daa8bfa6f2cc7445def417ff177 100644
--- a/crates/sidebar/Cargo.toml
+++ b/crates/sidebar/Cargo.toml
@@ -24,6 +24,7 @@ agent_ui = { workspace = true, features = ["audio"] }
anyhow.workspace = true
chrono.workspace = true
editor.workspace = true
+feature_flags.workspace = true
fs.workspace = true
git.workspace = true
gpui.workspace = true
diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs
index e18e91fb02139fd79447c6adb8171a43d55be6b3..08fad745fcfa2e1719f5ba40a67cb589e1118024 100644
--- a/crates/sidebar/src/sidebar.rs
+++ b/crates/sidebar/src/sidebar.rs
@@ -12,12 +12,15 @@ use agent_ui::threads_archive_view::{
ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
};
use agent_ui::{
- AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, CrossChannelImportOnboarding,
- DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread, ThreadId, ThreadImportModal,
+ AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, ArchiveSelectedThread,
+ CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewThread, ThreadId, ThreadImportModal,
channels_with_threads, import_threads_from_other_channels,
};
use chrono::{DateTime, Utc};
use editor::Editor;
+use feature_flags::{
+ AgentThreadWorktreeLabel, AgentThreadWorktreeLabelFlag, FeatureFlag, FeatureFlagAppExt as _,
+};
use gpui::{
Action as _, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EntityId, FocusHandle,
Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task, WeakEntity,
@@ -50,8 +53,8 @@ use util::path_list::PathList;
use workspace::{
CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, NextProject,
NextThread, Open, OpenMode, PreviousProject, PreviousThread, ProjectGroupKey, SaveIntent,
- ShowFewerThreads, ShowMoreThreads, Sidebar as WorkspaceSidebar, SidebarSide, Toast,
- ToggleWorkspaceSidebar, Workspace, notifications::NotificationId, sidebar_side_context_menu,
+ Sidebar as WorkspaceSidebar, SidebarSide, Toast, ToggleWorkspaceSidebar, Workspace,
+ notifications::NotificationId, sidebar_side_context_menu,
};
use zed_actions::OpenRecent;
@@ -69,8 +72,8 @@ gpui::actions!(
[
/// Creates a new thread in the currently selected or active project group.
NewThreadInGroup,
- /// Toggles between the thread list and the archive view.
- ViewAllThreads,
+ /// Toggles between the thread list and the thread history.
+ ToggleThreadHistory,
]
);
@@ -85,13 +88,13 @@ gpui::actions!(
const DEFAULT_WIDTH: Pixels = px(300.0);
const MIN_WIDTH: Pixels = px(200.0);
const MAX_WIDTH: Pixels = px(800.0);
-const DEFAULT_THREADS_SHOWN: usize = 5;
#[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
enum SerializedSidebarView {
#[default]
ThreadList,
- Archive,
+ #[serde(alias = "Archive")]
+ History,
}
#[derive(Default, Serialize, Deserialize)]
@@ -229,10 +232,6 @@ enum ListEntry {
has_threads: bool,
},
Thread(ThreadEntry),
- ViewMore {
- key: ProjectGroupKey,
- is_fully_expanded: bool,
- },
}
#[cfg(test)]
@@ -257,7 +256,6 @@ impl ListEntry {
ListEntry::ProjectHeader { key, .. } => multi_workspace
.workspaces_for_project_group(key, cx)
.unwrap_or_default(),
- ListEntry::ViewMore { .. } => Vec::new(),
}
}
}
@@ -330,6 +328,96 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList {
PathList::new(&workspace.read(cx).root_paths(cx))
}
+#[derive(Clone)]
+struct WorkspaceMenuWorktreeLabel {
+ icon: Option,
+ primary_name: SharedString,
+ secondary_name: Option,
+}
+
+fn workspace_menu_worktree_labels(
+ workspace: &Entity,
+ cx: &App,
+) -> Vec {
+ let root_paths = workspace.read(cx).root_paths(cx);
+ let show_folder_name = root_paths.len() > 1;
+ let project = workspace.read(cx).project().clone();
+ let repository_snapshots: Vec<_> = project
+ .read(cx)
+ .repositories(cx)
+ .values()
+ .map(|repo| repo.read(cx).snapshot())
+ .collect();
+
+ root_paths
+ .into_iter()
+ .map(|root_path| {
+ let root_path = root_path.as_ref();
+ let folder_name = root_path
+ .file_name()
+ .map(|name| SharedString::from(name.to_string_lossy().to_string()))
+ .unwrap_or_default();
+ let repository_snapshot = repository_snapshots
+ .iter()
+ .find(|snapshot| snapshot.work_directory_abs_path.as_ref() == root_path);
+
+ if let Some(snapshot) = repository_snapshot
+ && snapshot.is_linked_worktree()
+ {
+ let worktree_name = project::linked_worktree_short_name(
+ snapshot.original_repo_abs_path.as_ref(),
+ root_path,
+ )
+ .unwrap_or_else(|| folder_name.clone());
+
+ if show_folder_name {
+ WorkspaceMenuWorktreeLabel {
+ icon: Some(IconName::GitWorktree),
+ primary_name: folder_name,
+ secondary_name: Some(worktree_name),
+ }
+ } else {
+ WorkspaceMenuWorktreeLabel {
+ icon: Some(IconName::GitWorktree),
+ primary_name: worktree_name,
+ secondary_name: None,
+ }
+ }
+ } else {
+ WorkspaceMenuWorktreeLabel {
+ icon: None,
+ primary_name: folder_name,
+ secondary_name: None,
+ }
+ }
+ })
+ .collect()
+}
+
+fn apply_worktree_label_mode(
+ mut worktrees: Vec,
+ mode: AgentThreadWorktreeLabel,
+) -> Vec {
+ match mode {
+ AgentThreadWorktreeLabel::Both => {}
+ AgentThreadWorktreeLabel::Worktree => {
+ for wt in &mut worktrees {
+ wt.branch_name = None;
+ }
+ }
+ AgentThreadWorktreeLabel::Branch => {
+ for wt in &mut worktrees {
+ // Fall back to showing the worktree name when no branch is
+ // known; an empty chip would be worse than a mismatched icon.
+ if wt.branch_name.is_some() {
+ wt.worktree_name = None;
+ }
+ }
+ }
+ }
+ worktrees
+}
+
/// Shows a [`RemoteConnectionModal`] on the given workspace and establishes
/// an SSH connection. Suitable for passing to
/// [`MultiWorkspace::find_or_create_workspace`] as the `connect_remote`
@@ -372,6 +460,7 @@ pub struct Sidebar {
view: SidebarView,
restoring_tasks: HashMap>,
recent_projects_popover_handle: PopoverMenuHandle,
+ project_header_menu_handles: HashMap>,
project_header_menu_ix: Option,
_subscriptions: Vec,
/// For the thread import banners, if there is just one we show "Import
@@ -392,6 +481,8 @@ impl Sidebar {
cx.on_focus_in(&focus_handle, window, Self::focus_in)
.detach();
+ AgentThreadWorktreeLabelFlag::watch(cx);
+
let filter_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_use_modal_editing(true);
@@ -465,6 +556,7 @@ impl Sidebar {
view: SidebarView::default(),
restoring_tasks: HashMap::new(),
recent_projects_popover_handle: PopoverMenuHandle::default(),
+ project_header_menu_handles: HashMap::new(),
project_header_menu_ix: None,
_subscriptions: Vec::new(),
import_banners_use_verbose_labels: None,
@@ -486,17 +578,6 @@ impl Sidebar {
.unwrap_or(false)
}
- fn group_extra_batches(&self, key: &ProjectGroupKey, cx: &App) -> usize {
- self.multi_workspace
- .upgrade()
- .and_then(|mw| {
- mw.read(cx)
- .group_state_by_key(key)
- .and_then(|state| state.visible_thread_count)
- })
- .unwrap_or(0)
- }
-
fn set_group_expanded(&self, key: &ProjectGroupKey, expanded: bool, cx: &mut Context) {
if let Some(mw) = self.multi_workspace.upgrade() {
mw.update(cx, |mw, cx| {
@@ -508,22 +589,6 @@ impl Sidebar {
}
}
- fn set_group_visible_thread_count(
- &self,
- key: &ProjectGroupKey,
- count: Option,
- cx: &mut Context,
- ) {
- if let Some(mw) = self.multi_workspace.upgrade() {
- mw.update(cx, |mw, cx| {
- if let Some(state) = mw.group_state_by_key_mut(key) {
- state.visible_thread_count = count;
- }
- mw.serialize(cx);
- });
- }
- }
-
fn is_active_workspace(&self, workspace: &Entity, cx: &App) -> bool {
self.multi_workspace
.upgrade()
@@ -671,8 +736,8 @@ impl Sidebar {
this.sync_active_entry_from_panel(_agent_panel, cx);
this.update_entries(cx);
}
- AgentPanelEvent::MessageSentOrQueued { thread_id } => {
- this.record_thread_message_sent_or_queued(thread_id, cx);
+ AgentPanelEvent::ThreadInteracted { thread_id } => {
+ this.record_thread_interacted(thread_id, cx);
this.update_entries(cx);
}
},
@@ -1210,7 +1275,10 @@ impl Sidebar {
}
let mut worktree_matched = false;
for worktree in &mut thread.worktrees {
- if let Some(positions) = fuzzy_match_positions(&query, &worktree.name) {
+ let Some(name) = worktree.worktree_name.as_ref() else {
+ continue;
+ };
+ if let Some(positions) = fuzzy_match_positions(&query, name) {
worktree.highlight_positions = positions;
worktree_matched = true;
}
@@ -1261,55 +1329,13 @@ impl Sidebar {
continue;
}
- let total = threads.len();
-
- let extra_batches = self.group_extra_batches(&group_key, cx);
- let threads_to_show =
- DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
- let count = threads_to_show.min(total);
-
- let mut promoted_threads: HashSet = HashSet::new();
-
- // Build visible entries in a single pass. Threads within
- // the cutoff are always shown. Threads beyond it are shown
- // only if they should be promoted (running, waiting, or
- // focused)
- for (index, thread) in threads.into_iter().enumerate() {
- let is_hidden = index >= count;
-
- if is_hidden {
- let is_notified = notified_threads.contains(&thread.metadata.thread_id);
- let is_promoted = thread.status == AgentThreadStatus::Running
- || thread.status == AgentThreadStatus::WaitingForConfirmation
- || is_notified
- || self.active_entry.as_ref().is_some_and(|active| {
- active.matches_entry(&ListEntry::Thread(thread.clone()))
- });
- if is_promoted {
- promoted_threads.insert(thread.metadata.thread_id);
- }
- let is_in_promoted = promoted_threads.contains(&thread.metadata.thread_id);
- if !is_in_promoted {
- continue;
- }
- }
-
+ for thread in threads {
if let Some(sid) = &thread.metadata.session_id {
current_session_ids.insert(sid.clone());
}
current_thread_ids.insert(thread.metadata.thread_id);
entries.push(thread.into());
}
-
- let visible = count + promoted_threads.len();
- let is_fully_expanded = visible >= total;
-
- if total > DEFAULT_THREADS_SHOWN {
- entries.push(ListEntry::ViewMore {
- key: group_key.clone(),
- is_fully_expanded,
- });
- }
}
}
@@ -1407,6 +1433,7 @@ impl Sidebar {
panel.active_thread_is_draft(cx)
|| panel.active_conversation_view().is_none()
});
+ self.project_header_menu_handles.entry(ix).or_default();
self.render_project_header(
ix,
false,
@@ -1423,10 +1450,6 @@ impl Sidebar {
)
}
ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
- ListEntry::ViewMore {
- key,
- is_fully_expanded,
- } => self.render_view_more(ix, key, *is_fully_expanded, is_selected, cx),
};
if is_group_header_after_first {
@@ -1487,15 +1510,14 @@ impl Sidebar {
let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
let is_collapsed = self.is_group_collapsed(key, cx);
- let (disclosure_icon, disclosure_tooltip) = if is_collapsed {
- (IconName::ChevronRight, "Expand Project")
+ let disclosure_icon = if is_collapsed {
+ IconName::ChevronRight
} else {
- (IconName::ChevronDown, "Collapse Project")
+ IconName::ChevronDown
};
let key_for_toggle = key.clone();
- let key_for_collapse = key.clone();
- let view_more_expanded = self.group_extra_batches(key, cx) > 0;
+ let key_for_focus = key.clone();
let label = if highlight_positions.is_empty() {
Label::new(label.clone())
@@ -1528,8 +1550,6 @@ impl Sidebar {
.group_name(group_name_for_gradient.clone())
};
- let is_ellipsis_menu_open = self.project_header_menu_ix == Some(ix);
-
let header = h_flex()
.id(id)
.group(&group_name)
@@ -1602,9 +1622,6 @@ impl Sidebar {
.child(gradient_overlay())
.child(
h_flex()
- .when(!is_ellipsis_menu_open && !has_active_draft, |this| {
- this.visible_on_hover(&group_name)
- })
.child(gradient_overlay())
.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
@@ -1621,6 +1638,7 @@ impl Sidebar {
)
.icon_size(IconSize::Small)
.when(has_active_draft, |this| this.icon_color(Color::Accent))
+ .when(!has_active_draft, |this| this.visible_on_hover(&group_name))
.tooltip(move |_, cx| {
Tooltip::for_action_in(
"Start New Agent Thread",
@@ -1641,71 +1659,36 @@ impl Sidebar {
},
))
})
- .when(has_threads && view_more_expanded && !is_collapsed, |this| {
- this.child(
- IconButton::new(
- SharedString::from(format!(
- "{id_prefix}project-header-collapse-{ix}",
- )),
- IconName::ListCollapse,
- )
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("Show Fewer Threads"))
- .on_click(cx.listener({
- let key_for_collapse = key_for_collapse.clone();
- move |this, _, _window, cx| {
- this.selection = None;
- this.set_group_visible_thread_count(
- &key_for_collapse,
- None,
- cx,
- );
- this.update_entries(cx);
- }
- })),
- )
- })
- .child(self.render_project_header_ellipsis_menu(ix, id_prefix, key, cx)),
+ .child(self.render_project_header_ellipsis_menu(
+ ix,
+ id_prefix,
+ key,
+ is_active,
+ has_threads,
+ &group_name,
+ cx,
+ )),
)
- .tooltip(Tooltip::element({
- move |_, cx| {
- v_flex()
- .gap_1()
- .child(Label::new(disclosure_tooltip))
- .child(
- h_flex()
- .pt_1()
- .border_t_1()
- .border_color(cx.theme().colors().border_variant)
- .child(h_flex().flex_shrink_0().children(render_modifiers(
- &Modifiers::secondary_key(),
- PlatformStyle::platform(),
- None,
- Some(TextSize::Default.rems(cx).into()),
- false,
- )))
- .child(
- Label::new("-click to activate most recent workspace")
- .color(Color::Muted),
- ),
- )
- .into_any_element()
+ .on_mouse_down(gpui::MouseButton::Right, {
+ let menu_handle = self
+ .project_header_menu_handles
+ .get(&ix)
+ .cloned()
+ .unwrap_or_default();
+ move |_, window, cx| {
+ cx.stop_propagation();
+ menu_handle.toggle(window, cx);
}
- }))
- .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| {
- if event.modifiers().platform {
- let key = key_for_toggle.clone();
- if let Some(workspace) = this.workspace_for_group(&key, cx) {
- this.activate_workspace(&workspace, window, cx);
+ })
+ .on_click(
+ cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
+ if event.modifiers().secondary() {
+ this.activate_or_open_workspace_for_group(&key_for_focus, window, cx);
} else {
- this.open_workspace_for_group(&key, window, cx);
+ this.toggle_collapse(&key_for_toggle, window, cx);
}
- this.selection = None;
- this.active_entry = None;
- } else {
- this.toggle_collapse(&key_for_toggle, window, cx);
- }
- }));
+ }),
+ );
if !is_collapsed && !has_threads {
v_flex()
@@ -1737,49 +1720,37 @@ impl Sidebar {
ix: usize,
id_prefix: &str,
project_group_key: &ProjectGroupKey,
+ is_active: bool,
+ has_threads: bool,
+ group_name: &SharedString,
cx: &mut Context,
) -> AnyElement {
let multi_workspace = self.multi_workspace.clone();
let project_group_key = project_group_key.clone();
- let show_menu = multi_workspace
+ let show_multi_project_entries = multi_workspace
.read_with(cx, |mw, _| {
project_group_key.host().is_none() && mw.project_group_keys().len() >= 2
})
.unwrap_or(false);
- if !show_menu {
- return IconButton::new(
- SharedString::from(format!("{id_prefix}-close-project-{ix}")),
- IconName::Close,
- )
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("Remove Project"))
- .on_click(cx.listener({
- move |_, _, window, cx| {
- multi_workspace
- .update(cx, |multi_workspace, cx| {
- multi_workspace
- .remove_project_group(&project_group_key, window, cx)
- .detach_and_log_err(cx);
- })
- .ok();
- }
- }))
- .into_any_element();
- }
-
let this = cx.weak_entity();
+ let trigger_id = SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}"));
+ let menu_handle = self
+ .project_header_menu_handles
+ .get(&ix)
+ .cloned()
+ .unwrap_or_default();
+ let is_menu_open = menu_handle.is_deployed();
+
PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
+ .with_handle(menu_handle)
.trigger(
- IconButton::new(
- SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
- IconName::Ellipsis,
- )
- .selected_style(ButtonStyle::Tinted(TintColor::Accent))
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("Toggle Project Menu")),
+ IconButton::new(trigger_id, IconName::Ellipsis)
+ .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+ .icon_size(IconSize::Small)
+ .when(!is_menu_open, |el| el.visible_on_hover(group_name)),
)
.on_open(Rc::new({
let this = this.clone();
@@ -1794,48 +1765,262 @@ impl Sidebar {
.menu(move |window, cx| {
let multi_workspace = multi_workspace.clone();
let project_group_key = project_group_key.clone();
+ let this_for_menu = this.clone();
+
+ let open_workspaces = multi_workspace
+ .read_with(cx, |multi_workspace, cx| {
+ multi_workspace
+ .workspaces_for_project_group(&project_group_key, cx)
+ .unwrap_or_default()
+ })
+ .unwrap_or_default();
+
+ let active_workspace = multi_workspace
+ .read_with(cx, |multi_workspace, _cx| {
+ multi_workspace.workspace().clone()
+ })
+ .ok();
+ let workspace_labels: Vec<_> = open_workspaces
+ .iter()
+ .map(|workspace| workspace_menu_worktree_labels(workspace, cx))
+ .collect();
+ let workspace_is_active: Vec<_> = open_workspaces
+ .iter()
+ .map(|workspace| active_workspace.as_ref() == Some(workspace))
+ .collect();
let menu =
ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| {
+ let menu = menu.end_slot_action(Box::new(menu::SecondaryConfirm));
let weak_menu = menu_cx.weak_entity();
- let menu = menu.entry(
- "Open Project in New Window",
- Some(Box::new(workspace::MoveProjectToNewWindow)),
- {
- let project_group_key = project_group_key.clone();
- let multi_workspace = multi_workspace.clone();
- move |window, cx| {
- multi_workspace
- .update(cx, |multi_workspace, cx| {
- multi_workspace
- .open_project_group_in_new_window(
+ let menu = menu.when(show_multi_project_entries, |this| {
+ this.entry(
+ "Open Project in New Window",
+ Some(Box::new(workspace::MoveProjectToNewWindow)),
+ {
+ let project_group_key = project_group_key.clone();
+ let multi_workspace = multi_workspace.clone();
+ move |window, cx| {
+ multi_workspace
+ .update(cx, |multi_workspace, cx| {
+ multi_workspace
+ .open_project_group_in_new_window(
+ &project_group_key,
+ window,
+ cx,
+ )
+ .detach_and_log_err(cx);
+ })
+ .ok();
+ }
+ },
+ )
+ });
+
+ let menu = menu
+ .custom_entry(
+ {
+ move |_window, cx| {
+ let action = h_flex()
+ .opacity(0.6)
+ .children(render_modifiers(
+ &Modifiers::secondary_key(),
+ PlatformStyle::platform(),
+ None,
+ Some(TextSize::Default.rems(cx).into()),
+ false,
+ ))
+ .child(Label::new("-click").color(Color::Muted));
+
+ let label = if has_threads {
+ "Focus Last Workspace"
+ } else {
+ "Focus Workspace"
+ };
+
+ h_flex()
+ .w_full()
+ .justify_between()
+ .gap_4()
+ .child(
+ Label::new(label)
+ .when(is_active, |s| s.color(Color::Disabled)),
+ )
+ .child(action)
+ .into_any_element()
+ }
+ },
+ {
+ let project_group_key = project_group_key.clone();
+ let this = this_for_menu.clone();
+ move |window, cx| {
+ if is_active {
+ return;
+ }
+ this.update(cx, |sidebar, cx| {
+ if let Some(workspace) =
+ sidebar.workspace_for_group(&project_group_key, cx)
+ {
+ sidebar.activate_workspace(&workspace, window, cx);
+ } else {
+ sidebar.open_workspace_for_group(
&project_group_key,
window,
cx,
- )
- .detach_and_log_err(cx);
+ );
+ }
+ sidebar.selection = None;
+ sidebar.active_entry = None;
})
.ok();
- }
- },
- );
+ }
+ },
+ )
+ .selectable(!is_active);
+
+ let menu = if open_workspaces.is_empty() {
+ menu
+ } else {
+ let mut menu = menu.separator().header("Open Workspaces");
+
+ for (
+ workspace_index,
+ ((workspace, workspace_label), is_active_workspace),
+ ) in open_workspaces
+ .iter()
+ .cloned()
+ .zip(workspace_labels.iter().cloned())
+ .zip(workspace_is_active.iter().copied())
+ .enumerate()
+ {
+ let activate_multi_workspace = multi_workspace.clone();
+ let close_multi_workspace = multi_workspace.clone();
+ let activate_weak_menu = weak_menu.clone();
+ let close_weak_menu = weak_menu.clone();
+ let activate_workspace = workspace.clone();
+ let close_workspace = workspace.clone();
+
+ menu = menu.custom_entry(
+ move |_window, _cx| {
+ let close_multi_workspace = close_multi_workspace.clone();
+ let close_weak_menu = close_weak_menu.clone();
+ let close_workspace = close_workspace.clone();
+ let label_color = if is_active_workspace {
+ Color::Accent
+ } else {
+ Color::Default
+ };
+ let row_group_name = SharedString::from(format!(
+ "workspace-menu-row-{workspace_index}"
+ ));
+
+ h_flex()
+ .group(&row_group_name)
+ .w_full()
+ .gap_2()
+ .justify_between()
+ .child(h_flex().min_w_0().gap_2().children(
+ workspace_label.iter().map(|label| {
+ h_flex()
+ .min_w_0()
+ .gap_0p5()
+ .when_some(label.icon, |this, icon| {
+ this.child(
+ Icon::new(icon)
+ .size(IconSize::XSmall)
+ .color(label_color),
+ )
+ })
+ .child(
+ Label::new(label.primary_name.clone())
+ .color(label_color)
+ .truncate(),
+ )
+ .when_some(
+ label.secondary_name.clone(),
+ |this, secondary_name| {
+ this.child(
+ Label::new(":")
+ .color(label_color)
+ .alpha(0.5),
+ )
+ .child(
+ Label::new(secondary_name)
+ .color(label_color)
+ .truncate(),
+ )
+ },
+ )
+ .into_any_element()
+ }),
+ ))
+ .child(
+ IconButton::new(
+ ("close-workspace", workspace_index),
+ IconName::Close,
+ )
+ .shape(ui::IconButtonShape::Square)
+ .visible_on_hover(&row_group_name)
+ .tooltip(Tooltip::text("Close Workspace"))
+ .on_click(move |_, window, cx| {
+ cx.stop_propagation();
+ window.prevent_default();
+ close_multi_workspace
+ .update(cx, |multi_workspace, cx| {
+ multi_workspace
+ .close_workspace(
+ &close_workspace,
+ window,
+ cx,
+ )
+ .detach_and_log_err(cx);
+ })
+ .ok();
+ close_weak_menu
+ .update(cx, |_, cx| cx.emit(DismissEvent))
+ .ok();
+ }),
+ )
+ .into_any_element()
+ },
+ move |window, cx| {
+ activate_multi_workspace
+ .update(cx, |multi_workspace, cx| {
+ multi_workspace.activate(
+ activate_workspace.clone(),
+ window,
+ cx,
+ );
+ })
+ .ok();
+ activate_weak_menu
+ .update(cx, |_, cx| cx.emit(DismissEvent))
+ .ok();
+ },
+ );
+ }
+
+ menu
+ };
let project_group_key = project_group_key.clone();
- let multi_workspace = multi_workspace.clone();
- menu.entry("Remove Project", None, move |window, cx| {
- multi_workspace
- .update(cx, |multi_workspace, cx| {
- multi_workspace
- .remove_project_group(&project_group_key, window, cx)
- .detach_and_log_err(cx);
- })
- .ok();
- weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
- })
+ let remove_multi_workspace = multi_workspace.clone();
+ menu.separator()
+ .entry("Remove Project", None, move |window, cx| {
+ remove_multi_workspace
+ .update(cx, |multi_workspace, cx| {
+ multi_workspace
+ .remove_project_group(&project_group_key, window, cx)
+ .detach_and_log_err(cx);
+ })
+ .ok();
+ weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
+ })
});
let this = this.clone();
+
window
.subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
this.update(cx, |sidebar, cx| {
@@ -2155,18 +2340,6 @@ impl Sidebar {
}
}
}
- ListEntry::ViewMore {
- key,
- is_fully_expanded,
- ..
- } => {
- let key = key.clone();
- if *is_fully_expanded {
- self.reset_thread_group_expansion(&key, cx);
- } else {
- self.expand_thread_group(&key, cx);
- }
- }
}
}
@@ -2733,7 +2906,7 @@ impl Sidebar {
self.update_entries(cx);
}
}
- Some(ListEntry::Thread(_) | ListEntry::ViewMore { .. }) => {
+ Some(ListEntry::Thread(_)) => {
for i in (0..ix).rev() {
if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i)
{
@@ -2760,7 +2933,7 @@ impl Sidebar {
// Find the group header for the current selection.
let header_ix = match self.contents.entries.get(ix) {
Some(ListEntry::ProjectHeader { .. }) => Some(ix),
- Some(ListEntry::Thread(_) | ListEntry::ViewMore { .. }) => (0..ix).rev().find(|&i| {
+ Some(ListEntry::Thread(_)) => (0..ix).rev().find(|&i| {
matches!(
self.contents.entries.get(i),
Some(ListEntry::ProjectHeader { .. })
@@ -3369,9 +3542,9 @@ impl Sidebar {
}
}
- fn remove_selected_thread(
+ fn archive_selected_thread(
&mut self,
- _: &RemoveSelectedThread,
+ _: &ArchiveSelectedThread,
window: &mut Window,
cx: &mut Context,
) {
@@ -3398,11 +3571,7 @@ impl Sidebar {
self.thread_last_accessed.insert(*id, Utc::now());
}
- fn record_thread_message_sent_or_queued(
- &mut self,
- thread_id: &agent_ui::ThreadId,
- cx: &mut App,
- ) {
+ fn record_thread_interacted(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut App) {
let store = ThreadMetadataStore::global(cx);
store.update(cx, |store, cx| {
store.update_interacted_at(thread_id, Utc::now(), cx);
@@ -3484,7 +3653,6 @@ impl Sidebar {
timestamp,
})
}
- _ => None,
})
.collect();
@@ -3706,6 +3874,11 @@ impl Sidebar {
let is_remote = thread.workspace.is_remote(cx);
+ let worktrees = apply_worktree_label_mode(
+ thread.worktrees.clone(),
+ cx.flag_value::(),
+ );
+
ThreadItem::new(id, title)
.base_bg(sidebar_bg)
.icon(thread.icon)
@@ -3714,7 +3887,7 @@ impl Sidebar {
.when_some(thread.icon_from_external_svg.clone(), |this, svg| {
this.custom_icon_from_external_svg(svg)
})
- .worktrees(thread.worktrees.clone())
+ .worktrees(worktrees)
.timestamp(timestamp)
.highlight_positions(thread.highlight_positions.to_vec())
.title_generating(thread.is_title_generating)
@@ -3760,7 +3933,7 @@ impl Sidebar {
move |_window, cx| {
Tooltip::for_action_in(
"Archive Thread",
- &RemoveSelectedThread,
+ &ArchiveSelectedThread,
&focus_handle,
cx,
)
@@ -3867,38 +4040,6 @@ impl Sidebar {
.anchor(gpui::Corner::BottomRight)
}
- fn render_view_more(
- &self,
- ix: usize,
- key: &ProjectGroupKey,
- is_fully_expanded: bool,
- is_selected: bool,
- cx: &mut Context,
- ) -> AnyElement {
- let key = key.clone();
- let id = SharedString::from(format!("view-more-{}", ix));
-
- let label: SharedString = if is_fully_expanded {
- "Collapse".into()
- } else {
- "View More".into()
- };
-
- ThreadItem::new(id, label)
- .focused(is_selected)
- .icon_visible(false)
- .title_label_color(Color::Muted)
- .on_click(cx.listener(move |this, _, _window, cx| {
- this.selection = None;
- if is_fully_expanded {
- this.reset_thread_group_expansion(&key, cx);
- } else {
- this.expand_thread_group(&key, cx);
- }
- }))
- .into_any_element()
- }
-
fn new_thread_in_group(
&mut self,
_: &NewThreadInGroup,
@@ -3955,7 +4096,7 @@ impl Sidebar {
let ix = self.selection?;
match self.contents.entries.get(ix) {
Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()),
- Some(ListEntry::Thread(_) | ListEntry::ViewMore { .. }) => {
+ Some(ListEntry::Thread(_)) => {
(0..ix)
.rev()
.find_map(|i| match self.contents.entries.get(i) {
@@ -3979,6 +4120,26 @@ impl Sidebar {
}
}
+ pub(crate) fn activate_or_open_workspace_for_group(
+ &mut self,
+ key: &ProjectGroupKey,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let workspace = self
+ .multi_workspace
+ .upgrade()
+ .and_then(|mw| mw.read(cx).last_active_workspace_for_group(key, cx))
+ .or_else(|| self.workspace_for_group(key, cx));
+ if let Some(workspace) = workspace {
+ self.activate_workspace(&workspace, window, cx);
+ } else {
+ self.open_workspace_for_group(key, window, cx);
+ }
+ self.selection = None;
+ self.active_entry = None;
+ }
+
fn active_project_group_key(&self, cx: &App) -> Option {
let multi_workspace = self.multi_workspace.upgrade()?;
let multi_workspace = multi_workspace.read(cx);
@@ -4132,59 +4293,6 @@ impl Sidebar {
self.cycle_thread_impl(false, window, cx);
}
- fn expand_thread_group(&mut self, project_group_key: &ProjectGroupKey, cx: &mut Context) {
- let current = self.group_extra_batches(project_group_key, cx);
- self.set_group_visible_thread_count(project_group_key, Some(current + 1), cx);
- self.update_entries(cx);
- }
-
- fn reset_thread_group_expansion(
- &mut self,
- project_group_key: &ProjectGroupKey,
- cx: &mut Context,
- ) {
- self.set_group_visible_thread_count(project_group_key, None, cx);
- self.update_entries(cx);
- }
-
- fn collapse_thread_group(
- &mut self,
- project_group_key: &ProjectGroupKey,
- cx: &mut Context,
- ) {
- let batches = self.group_extra_batches(project_group_key, cx);
- match batches {
- 0 => return,
- 1 => self.set_group_visible_thread_count(project_group_key, None, cx),
- _ => self.set_group_visible_thread_count(project_group_key, Some(batches - 1), cx),
- }
- self.update_entries(cx);
- }
-
- fn on_show_more_threads(
- &mut self,
- _: &ShowMoreThreads,
- _window: &mut Window,
- cx: &mut Context,
- ) {
- let Some(active_key) = self.active_project_group_key(cx) else {
- return;
- };
- self.expand_thread_group(&active_key, cx);
- }
-
- fn on_show_fewer_threads(
- &mut self,
- _: &ShowFewerThreads,
- _window: &mut Window,
- cx: &mut Context,
- ) {
- let Some(active_key) = self.active_project_group_key(cx) else {
- return;
- };
- self.collapse_thread_group(&active_key, cx);
- }
-
fn render_no_results(&self, cx: &mut Context) -> impl IntoElement {
let has_query = self.has_filter_query(cx);
let message = if has_query {
@@ -4397,45 +4505,33 @@ impl Sidebar {
fn render_sidebar_bottom_bar(&mut self, cx: &mut Context) -> impl IntoElement {
let is_archive = matches!(self.view, SidebarView::Archive(..));
- let show_import_button = is_archive && !self.should_render_acp_import_onboarding(cx);
let on_right = self.side(cx) == SidebarSide::Right;
- let action_buttons = h_flex()
+ h_flex()
+ .p_1()
.gap_1()
.when(on_right, |this| this.flex_row_reverse())
- .when(show_import_button, |this| {
- this.child(
- IconButton::new("thread-import", IconName::ThreadImport)
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("Import External Agent Threads"))
- .on_click(cx.listener(|this, _, window, cx| {
- this.show_archive(window, cx);
- this.show_thread_import_modal(window, cx);
- })),
- )
- })
+ .border_t_1()
+ .border_color(cx.theme().colors().border)
+ .child(self.render_sidebar_toggle_button(cx))
.child(
- IconButton::new("archive", IconName::Archive)
+ IconButton::new("history", IconName::Clock)
.icon_size(IconSize::Small)
.toggle_state(is_archive)
.tooltip(move |_, cx| {
- Tooltip::for_action("View All Threads", &ViewAllThreads, cx)
+ let label = if is_archive {
+ "Hide Thread History"
+ } else {
+ "Show Thread History"
+ };
+ Tooltip::for_action(label, &ToggleThreadHistory, cx)
})
.on_click(cx.listener(|this, _, window, cx| {
- this.toggle_archive(&ViewAllThreads, window, cx);
+ this.toggle_archive(&ToggleThreadHistory, window, cx);
})),
)
- .child(self.render_recent_projects_button(cx));
-
- h_flex()
- .p_1()
- .gap_1()
- .when(on_right, |this| this.flex_row_reverse())
- .justify_between()
- .border_t_1()
- .border_color(cx.theme().colors().border)
- .child(self.render_sidebar_toggle_button(cx))
- .child(action_buttons)
+ .child(div().flex_1())
+ .child(self.render_recent_projects_button(cx))
}
fn active_workspace(&self, cx: &App) -> Option> {
@@ -4561,14 +4657,19 @@ impl Sidebar {
)
}
- fn toggle_archive(&mut self, _: &ViewAllThreads, window: &mut Window, cx: &mut Context) {
+ fn toggle_archive(
+ &mut self,
+ _: &ToggleThreadHistory,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
match &self.view {
SidebarView::ThreadList => {
let side = match self.side(cx) {
SidebarSide::Left => "left",
SidebarSide::Right => "right",
};
- telemetry::event!("Sidebar Archive Viewed", side = side);
+ telemetry::event!("Thread History Viewed", side = side);
self.show_archive(window, cx);
}
SidebarView::Archive(_) => self.show_thread_list(window, cx),
@@ -4619,6 +4720,9 @@ impl Sidebar {
ThreadsArchiveViewEvent::CancelRestore { thread_id } => {
this.restoring_tasks.remove(thread_id);
}
+ ThreadsArchiveViewEvent::Import => {
+ this.show_thread_import_modal(window, cx);
+ }
},
);
@@ -4691,7 +4795,7 @@ fn render_import_onboarding_banner(
.style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
.label_size(LabelSize::Small)
.start_icon(
- Icon::new(IconName::ThreadImport)
+ Icon::new(IconName::Download)
.size(IconSize::Small)
.color(Color::Muted),
)
@@ -4748,7 +4852,7 @@ impl WorkspaceSidebar for Sidebar {
width: Some(f32::from(self.width)),
active_view: match self.view {
SidebarView::ThreadList => SerializedSidebarView::ThreadList,
- SidebarView::Archive(_) => SerializedSidebarView::Archive,
+ SidebarView::Archive(_) => SerializedSidebarView::History,
},
};
serde_json::to_string(&serialized).ok()
@@ -4764,7 +4868,7 @@ impl WorkspaceSidebar for Sidebar {
if let Some(width) = serialized.width {
self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH);
}
- if serialized.active_view == SerializedSidebarView::Archive {
+ if serialized.active_view == SerializedSidebarView::History {
cx.defer_in(window, |this, window, cx| {
this.show_archive(window, cx);
});
@@ -4813,7 +4917,7 @@ impl Render for Sidebar {
.on_action(cx.listener(Self::fold_all))
.on_action(cx.listener(Self::unfold_all))
.on_action(cx.listener(Self::cancel))
- .on_action(cx.listener(Self::remove_selected_thread))
+ .on_action(cx.listener(Self::archive_selected_thread))
.on_action(cx.listener(Self::new_thread_in_group))
.on_action(cx.listener(Self::toggle_archive))
.on_action(cx.listener(Self::focus_sidebar_filter))
@@ -4822,8 +4926,6 @@ impl Render for Sidebar {
.on_action(cx.listener(Self::on_previous_project))
.on_action(cx.listener(Self::on_next_thread))
.on_action(cx.listener(Self::on_previous_thread))
- .on_action(cx.listener(Self::on_show_more_threads))
- .on_action(cx.listener(Self::on_show_fewer_threads))
.on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
this.recent_projects_popover_handle.toggle(window, cx);
}))
diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs
index 7ea2d96ee54a7f616d57018183323c865ae6277f..bd266fb88c3657c6e28c49ccfd364edb919b649a 100644
--- a/crates/sidebar/src/sidebar_tests.rs
+++ b/crates/sidebar/src/sidebar_tests.rs
@@ -147,11 +147,6 @@ fn assert_remote_project_integration_sidebar_state(
title
);
}
- ListEntry::ViewMore { .. } => {
- panic!(
- "unexpected `View More` entry while simulating remote project integration flicker"
- );
- }
}
}
@@ -454,9 +449,12 @@ fn format_linked_worktree_chips(worktrees: &[ThreadItemWorktreeInfo]) -> String
if wt.kind == ui::WorktreeKind::Main {
continue;
}
- if !seen.contains(&wt.name) {
- seen.push(wt.name.clone());
- chips.push(format!("{{{}}}", wt.name));
+ let Some(name) = wt.worktree_name.as_ref() else {
+ continue;
+ };
+ if !seen.contains(name) {
+ seen.push(name.clone());
+ chips.push(format!("{{{}}}", name));
}
}
if chips.is_empty() {
@@ -519,15 +517,6 @@ fn visible_entries_as_strings(
format!(" {title}{worktree}{live}{status_str}{notified}{selected}")
}
}
- ListEntry::ViewMore {
- is_fully_expanded, ..
- } => {
- if *is_fully_expanded {
- format!(" - Collapse{}", selected)
- } else {
- format!(" + View More{}", selected)
- }
- }
}
})
.collect()
@@ -545,7 +534,7 @@ async fn test_serialization_round_trip(cx: &mut TestAppContext) {
let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
- // Set a custom width, collapse the group, and expand "View More".
+ // Set a custom width and collapse the group.
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.set_width(Some(px(420.0)), cx);
sidebar.toggle_collapse(&project_group_key, window, cx);
@@ -587,7 +576,7 @@ async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppCon
let serialized = serde_json::to_string(&SerializedSidebar {
width: Some(400.0),
- active_view: SerializedSidebarView::Archive,
+ active_view: SerializedSidebarView::History,
})
.expect("serialization should succeed");
@@ -730,101 +719,6 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
);
}
-#[gpui::test]
-async fn test_view_more_pagination(cx: &mut TestAppContext) {
- let project = init_test_project("/my-project", cx).await;
- let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
- let sidebar = setup_sidebar(&multi_workspace, cx);
-
- save_n_test_threads(12, &project, cx).await;
-
- multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
- cx.run_until_parked();
-
- assert_eq!(
- visible_entries_as_strings(&sidebar, cx),
- vec![
- //
- "v [my-project]",
- " Thread 12",
- " Thread 11",
- " Thread 10",
- " Thread 9",
- " Thread 8",
- " + View More",
- ]
- );
-}
-
-#[gpui::test]
-async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
- let project = init_test_project("/my-project", cx).await;
- let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
- let sidebar = setup_sidebar(&multi_workspace, cx);
-
- // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
- save_n_test_threads(17, &project, cx).await;
-
- let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
-
- multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
- cx.run_until_parked();
-
- // Initially shows 5 threads + View More
- let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 7); // header + 5 threads + View More
- assert!(entries.iter().any(|e| e.contains("View More")));
-
- // Focus and navigate to View More, then confirm to expand by one batch
- focus_sidebar(&sidebar, cx);
- for _ in 0..7 {
- cx.dispatch_action(SelectNext);
- }
- cx.dispatch_action(Confirm);
- cx.run_until_parked();
-
- // Now shows 10 threads + View More
- let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 12); // header + 10 threads + View More
- assert!(entries.iter().any(|e| e.contains("View More")));
-
- // Expand again by one batch
- sidebar.update_in(cx, |s, _window, cx| {
- s.expand_thread_group(&project_group_key, cx);
- });
- cx.run_until_parked();
-
- // Now shows 15 threads + View More
- let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 17); // header + 15 threads + View More
- assert!(entries.iter().any(|e| e.contains("View More")));
-
- // Expand one more time - should show all 17 threads with Collapse button
- sidebar.update_in(cx, |s, _window, cx| {
- s.expand_thread_group(&project_group_key, cx);
- });
- cx.run_until_parked();
-
- // All 17 threads shown with Collapse button
- let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
- assert!(!entries.iter().any(|e| e.contains("View More")));
- assert!(entries.iter().any(|e| e.contains("Collapse")));
-
- // Click collapse - should go back to showing 5 threads
- sidebar.update_in(cx, |s, _window, cx| {
- s.reset_thread_group_expansion(&project_group_key, cx);
- });
- cx.run_until_parked();
-
- // Back to initial state: 5 threads + View More
- let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 7); // header + 5 threads + View More
- assert!(entries.iter().any(|e| e.contains("View More")));
-}
-
#[gpui::test]
async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
@@ -948,7 +842,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
key: ProjectGroupKey::new(None, collapsed_path.clone()),
workspaces: Vec::new(),
expanded: false,
- visible_thread_count: None,
});
});
@@ -1092,11 +985,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
worktrees: Vec::new(),
diff_stats: DiffStats::default(),
}),
- // View More entry
- ListEntry::ViewMore {
- key: ProjectGroupKey::new(None, expanded_path.clone()),
- is_fully_expanded: false,
- },
// Collapsed project header
ListEntry::ProjectHeader {
key: ProjectGroupKey::new(None, collapsed_path.clone()),
@@ -1123,14 +1011,13 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
" Error thread * (error)",
" Waiting thread (waiting)",
" Notified thread * (!)",
- " + View More",
"> [collapsed-project]",
]
);
// Move selection to the collapsed header
sidebar.update_in(cx, |s, _window, _cx| {
- s.selection = Some(7);
+ s.selection = Some(6);
});
assert_eq!(
@@ -1319,40 +1206,6 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA
);
}
-#[gpui::test]
-async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
- let project = init_test_project("/my-project", cx).await;
- let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
- let sidebar = setup_sidebar(&multi_workspace, cx);
-
- save_n_test_threads(8, &project, cx).await;
- multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
- cx.run_until_parked();
-
- // Should show header + 5 threads + "View More"
- let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 7);
- assert!(entries.iter().any(|e| e.contains("View More")));
-
- // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
- focus_sidebar(&sidebar, cx);
- for _ in 0..7 {
- cx.dispatch_action(SelectNext);
- }
- assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
-
- // Confirm on "View More" to expand
- cx.dispatch_action(Confirm);
- cx.run_until_parked();
-
- // All 8 threads should now be visible with a "Collapse" button
- let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
- assert!(!entries.iter().any(|e| e.contains("View More")));
- assert!(entries.iter().any(|e| e.contains("Collapse")));
-}
-
#[gpui::test]
async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
@@ -2090,61 +1943,6 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
);
}
-#[gpui::test]
-async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
- let project = init_test_project("/my-project", cx).await;
- let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
- let sidebar = setup_sidebar(&multi_workspace, cx);
-
- // Create 8 threads. The oldest one has a unique name and will be
- // behind View More (only 5 shown by default).
- for i in 0..8u32 {
- let title = if i == 0 {
- "Hidden gem thread".to_string()
- } else {
- format!("Thread {}", i + 1)
- };
- save_thread_metadata(
- acp::SessionId::new(Arc::from(format!("thread-{}", i))),
- Some(title.into()),
- chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
- None,
- None,
- &project,
- cx,
- )
- }
- cx.run_until_parked();
-
- // Confirm the thread is not visible and View More is shown.
- let entries = visible_entries_as_strings(&sidebar, cx);
- assert!(
- entries.iter().any(|e| e.contains("View More")),
- "should have View More button"
- );
- assert!(
- !entries.iter().any(|e| e.contains("Hidden gem")),
- "Hidden gem should be behind View More"
- );
-
- // User searches for the hidden thread — it appears, and View More is gone.
- type_in_search(&sidebar, "hidden gem", cx);
- let filtered = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(
- filtered,
- vec![
- //
- "v [my-project]",
- " Hidden gem thread <== selected",
- ]
- );
- assert!(
- !filtered.iter().any(|e| e.contains("View More")),
- "View More should not appear when filtering"
- );
-}
-
#[gpui::test]
async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
@@ -2454,7 +2252,6 @@ async fn test_confirm_on_historical_thread_in_new_project_group_opens_real_threa
key: project_b_key.clone(),
workspaces: Vec::new(),
expanded: true,
- visible_thread_count: None,
});
});
@@ -4043,7 +3840,10 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje
}
ListEntry::Thread(thread)
if thread.metadata.title.as_ref().map(|t| t.as_ref()) == Some("WT Thread")
- && thread.worktrees.first().map(|wt| wt.name.as_ref())
+ && thread
+ .worktrees
+ .first()
+ .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
== Some("wt-feature-a") =>
{
saw_expected_thread = true;
@@ -4053,16 +3853,13 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje
let worktree_name = thread
.worktrees
.first()
- .map(|wt| wt.name.as_ref())
+ .and_then(|wt| wt.worktree_name.as_ref().map(|n| n.as_ref()))
.unwrap_or("");
panic!(
"unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
title, worktree_name
);
}
- ListEntry::ViewMore { .. } => {
- panic!("unexpected `View More` entry while opening linked worktree thread");
- }
}
}
@@ -10635,7 +10432,7 @@ fn test_worktree_info_branch_names_for_main_worktrees() {
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
assert_eq!(infos[0].branch_name, Some(SharedString::from("feature-x")));
- assert_eq!(infos[0].name, SharedString::from("myapp"));
+ assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
}
#[test]
@@ -10672,7 +10469,7 @@ fn test_worktree_info_missing_branch_returns_none() {
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].kind, ui::WorktreeKind::Main);
assert_eq!(infos[0].branch_name, None);
- assert_eq!(infos[0].name, SharedString::from("myapp"));
+ assert_eq!(infos[0].worktree_name, Some(SharedString::from("myapp")));
}
#[gpui::test]
@@ -11044,3 +10841,129 @@ async fn test_collab_guest_move_thread_paths_is_noop(cx: &mut TestAppContext) {
);
});
}
+
+#[gpui::test]
+async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_workspace(
+ cx: &mut TestAppContext,
+) {
+ // Regression test for: cmd-clicking a project group header should return
+ // the user to the workspace they most recently had active in that group,
+ // including workspaces rooted at a linked worktree.
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+
+ fs.insert_tree(
+ "/project-a",
+ serde_json::json!({
+ ".git": {},
+ "src": {},
+ }),
+ )
+ .await;
+ fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
+ .await;
+
+ fs.add_linked_worktree_for_repo(
+ Path::new("/project-a/.git"),
+ false,
+ git::repository::Worktree {
+ path: std::path::PathBuf::from("/wt-feature-a"),
+ ref_name: Some("refs/heads/feature-a".into()),
+ sha: "aaa".into(),
+ is_main: false,
+ is_bare: false,
+ },
+ )
+ .await;
+
+ cx.update(|cx|