Feature flag overrides (#54206)

Mikayla Maki created

This PR revamps our feature flag system, to enable richer iteration. 

Feature flags can now:
- Support enum values, for richer configuration
- Be manually set via the settings file
- Be manually set via the settings UI

This PR also adds a feature flag to demonstrate this behavior, a
`agent-thread-worktree-label`, which controls which how the worktree tag
UI displays.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

Change summary

Cargo.lock                                              |  18 
Cargo.toml                                              |   3 
crates/agent_ui/src/thread_metadata_store.rs            |  13 
crates/csv_preview/src/csv_preview.rs                   |   4 
crates/debugger_ui/src/debugger_panel.rs                |   4 
crates/edit_prediction/src/edit_prediction.rs           |   4 
crates/edit_prediction_ui/src/edit_prediction_ui.rs     |   4 
crates/edit_prediction_ui/src/rate_prediction_modal.rs  |   4 
crates/feature_flags/Cargo.toml                         |  11 
crates/feature_flags/src/feature_flags.rs               | 191 ++++-
crates/feature_flags/src/flags.rs                       |  41 +
crates/feature_flags/src/settings.rs                    |  76 ++
crates/feature_flags/src/store.rs                       | 374 +++++++++++
crates/feature_flags_macros/Cargo.toml                  |  18 
crates/feature_flags_macros/LICENSE-GPL                 |   1 
crates/feature_flags_macros/src/feature_flags_macros.rs | 190 +++++
crates/json_schema_store/Cargo.toml                     |   5 
crates/json_schema_store/src/json_schema_store.rs       |  55 +
crates/repl/src/notebook/notebook_ui.rs                 |   4 
crates/settings/src/vscode_import.rs                    |   1 
crates/settings_content/src/settings_content.rs         |  39 +
crates/settings_ui/src/page_data.rs                     |  29 
crates/settings_ui/src/pages.rs                         |   2 
crates/settings_ui/src/pages/feature_flags.rs           | 132 +++
crates/settings_ui/src/settings_ui.rs                   |  20 
crates/sidebar/Cargo.toml                               |   1 
crates/sidebar/src/sidebar.rs                           |  41 +
crates/sidebar/src/sidebar_tests.rs                     |  20 
crates/ui/src/components/ai/thread_item.rs              |  89 +
crates/zed/src/main.rs                                  |   1 
crates/zed/src/visual_test_runner.rs                    |  18 
crates/zed/src/zed.rs                                   |   4 
32 files changed, 1,289 insertions(+), 128 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6180,7 +6180,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]]
@@ -9166,6 +9182,7 @@ dependencies = [
  "collections",
  "dap",
  "extension",
+ "feature_flags",
  "gpui",
  "language",
  "parking_lot",
@@ -16120,6 +16137,7 @@ dependencies = [
  "db",
  "editor",
  "extension",
+ "feature_flags",
  "fs",
  "git",
  "gpui",

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",
@@ -326,6 +327,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" }
@@ -894,6 +896,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 }

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -346,7 +346,7 @@ pub fn worktree_info_from_thread_paths<S: std::hash::BuildHasher>(
                 .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<S: std::hash::BuildHasher>(
                 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<S: std::hash::BuildHasher>(
     // 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<S: std::hash::BuildHasher>(
             .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)));
+            }
         }
     }
 

crates/csv_preview/src/csv_preview.rs 🔗

@@ -1,5 +1,5 @@
 use editor::{Editor, EditorEvent};
-use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
+use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag};
 use gpui::{
     AppContext, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, Task, actions,
 };
@@ -29,7 +29,9 @@ pub struct TabularDataPreviewFeatureFlag;
 
 impl FeatureFlag for TabularDataPreviewFeatureFlag {
     const NAME: &'static str = "tabular-data-preview";
+    type Value = PresenceFlag;
 }
+register_feature_flag!(TabularDataPreviewFeatureFlag);
 
 pub struct CsvPreviewView {
     pub(crate) engine: TableDataEngine,

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -15,7 +15,7 @@ use dap::adapters::DebugAdapterName;
 use dap::{DapRegistry, StartDebuggingRequestArguments};
 use dap::{client::SessionId, debugger_settings::DebuggerSettings};
 use editor::{Editor, MultiBufferOffset, ToPoint};
-use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
+use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag};
 use gpui::{
     Action, App, AsyncWindowContext, ClipboardItem, Context, Corner, DismissEvent, Entity,
     EntityId, EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point,
@@ -50,7 +50,9 @@ pub struct DebuggerHistoryFeatureFlag;
 
 impl FeatureFlag for DebuggerHistoryFeatureFlag {
     const NAME: &'static str = "debugger-history";
+    type Value = PresenceFlag;
 }
+register_feature_flag!(DebuggerHistoryFeatureFlag);
 
 const DEBUG_PANEL_KEY: &str = "DebugPanel";
 

crates/edit_prediction/src/edit_prediction.rs 🔗

@@ -17,7 +17,7 @@ use copilot::{Copilot, Reinstall, SignIn, SignOut};
 use credentials_provider::CredentialsProvider;
 use db::kvp::{Dismissable, KeyValueStore};
 use edit_prediction_context::{RelatedExcerptStore, RelatedExcerptStoreEvent, RelatedFile};
-use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
+use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag};
 use futures::{
     AsyncReadExt as _, FutureExt as _, StreamExt as _,
     channel::mpsc::{self, UnboundedReceiver},
@@ -120,7 +120,9 @@ pub struct EditPredictionJumpsFeatureFlag;
 
 impl FeatureFlag for EditPredictionJumpsFeatureFlag {
     const NAME: &'static str = "edit_prediction_jumps";
+    type Value = PresenceFlag;
 }
+register_feature_flag!(EditPredictionJumpsFeatureFlag);
 
 #[derive(Clone)]
 struct EditPredictionStoreGlobal(Entity<EditPredictionStore>);

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::<PredictEditsRatePredictionsFeatureFlag, _>(move |is_enabled, cx| {
+    cx.observe_flag::<PredictEditsRatePredictionsFeatureFlag, _>(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);
                 });

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

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"] }

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<String>,
-    staff: bool,
-}
+pub use settings::{FeatureFlagsSettings, generate_feature_flags_schema};
+pub use store::*;
 
 pub static ZED_DISABLE_STAFF: LazyLock<bool> = LazyLock::new(|| {
     std::env::var("ZED_DISABLE_STAFF").is_ok_and(|value| !value.is_empty() && value != "0")
 });
 
-impl FeatureFlags {
-    fn has_flag<T: FeatureFlag>(&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<Self>;
+
+    /// 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<Self> {
+        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<V: 'static>(cx: &mut Context<V>) {
+        cx.observe_global::<FeatureFlagStore>(|_, cx| cx.notify())
+            .detach();
+    }
 }
 
 pub trait FeatureFlagViewExt<V: 'static> {
+    /// Fires the callback whenever the resolved [`T::Value`] transitions.
     fn observe_flag<T: FeatureFlag, F>(&mut self, window: &Window, callback: F) -> Subscription
     where
-        F: Fn(bool, &mut V, &mut Window, &mut Context<V>) + Send + Sync + 'static;
+        F: Fn(T::Value, &mut V, &mut Window, &mut Context<V>) + Send + Sync + 'static;
 
     fn when_flag_enabled<T: FeatureFlag>(
         &mut self,
@@ -75,11 +161,16 @@ where
 {
     fn observe_flag<T: FeatureFlag, F>(&mut self, window: &Window, callback: F) -> Subscription
     where
-        F: Fn(bool, &mut V, &mut Window, &mut Context<V>) + 'static,
+        F: Fn(T::Value, &mut V, &mut Window, &mut Context<V>) + 'static,
     {
-        self.observe_global_in::<FeatureFlags>(window, move |v, window, cx| {
-            let feature_flags = cx.global::<FeatureFlags>();
-            callback(feature_flags.has_flag::<T>(), v, window, cx);
+        let mut last_value: Option<T::Value> = None;
+        self.observe_global_in::<FeatureFlagStore>(window, move |v, window, cx| {
+            let value = cx.flag_value::<T>();
+            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<V>) + Send + Sync + 'static,
     ) {
         if self
-            .try_global::<FeatureFlags>()
-            .is_some_and(|f| f.has_flag::<T>())
+            .try_global::<FeatureFlagStore>()
+            .is_some_and(|f| f.has_flag::<T>(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::<FeatureFlags>(window, {
+        let inner = self.observe_global_in::<FeatureFlagStore>(window, {
             let subscription = subscription.clone();
             move |v, window, cx| {
-                let feature_flags = cx.global::<FeatureFlags>();
-                if feature_flags.has_flag::<T>() {
+                let has_flag = cx.global::<FeatureFlagStore>().has_flag::<T>(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<String>);
     fn set_staff(&mut self, staff: bool);
     fn has_flag<T: FeatureFlag>(&self) -> bool;
+    fn flag_value<T: FeatureFlag>(&self) -> T::Value;
     fn is_staff(&self) -> bool;
 
     fn on_flags_ready<F>(&mut self, callback: F) -> Subscription
@@ -129,33 +221,35 @@ pub trait FeatureFlagAppExt {
 
     fn observe_flag<T: FeatureFlag, F>(&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<String>) {
-        let feature_flags = self.default_global::<FeatureFlags>();
-        feature_flags.staff = staff;
-        feature_flags.flags = flags;
+        let store = self.default_global::<FeatureFlagStore>();
+        store.update_server_flags(staff, flags);
     }
 
     fn set_staff(&mut self, staff: bool) {
-        let feature_flags = self.default_global::<FeatureFlags>();
-        feature_flags.staff = staff;
+        let store = self.default_global::<FeatureFlagStore>();
+        store.set_staff(staff);
     }
 
     fn has_flag<T: FeatureFlag>(&self) -> bool {
-        self.try_global::<FeatureFlags>()
-            .map(|flags| flags.has_flag::<T>())
-            .unwrap_or_else(|| {
-                (cfg!(debug_assertions) && T::enabled_for_staff() && !*ZED_DISABLE_STAFF)
-                    || T::enabled_for_all()
-            })
+        self.try_global::<FeatureFlagStore>()
+            .map(|store| store.has_flag::<T>(self))
+            .unwrap_or_else(|| FeatureFlagStore::has_flag_default::<T>())
+    }
+
+    fn flag_value<T: FeatureFlag>(&self) -> T::Value {
+        self.try_global::<FeatureFlagStore>()
+            .and_then(|store| store.try_flag_value::<T>(self))
+            .unwrap_or_default()
     }
 
     fn is_staff(&self) -> bool {
-        self.try_global::<FeatureFlags>()
-            .map(|flags| flags.staff)
+        self.try_global::<FeatureFlagStore>()
+            .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::<FeatureFlags>(move |cx| {
-            let feature_flags = cx.global::<FeatureFlags>();
+        self.observe_global::<FeatureFlagStore>(move |cx| {
+            let store = cx.global::<FeatureFlagStore>();
             callback(
                 OnFlagsReady {
-                    is_staff: feature_flags.staff,
+                    is_staff: store.is_staff(),
                 },
                 cx,
             );
@@ -176,11 +270,16 @@ impl FeatureFlagAppExt for App {
 
     fn observe_flag<T: FeatureFlag, F>(&mut self, mut callback: F) -> Subscription
     where
-        F: FnMut(bool, &mut App) + 'static,
+        F: FnMut(T::Value, &mut App) + 'static,
     {
-        self.observe_global::<FeatureFlags>(move |cx| {
-            let feature_flags = cx.global::<FeatureFlags>();
-            callback(feature_flags.has_flag::<T>(), cx);
+        let mut last_value: Option<T::Value> = None;
+        self.observe_global::<FeatureFlagStore>(move |cx| {
+            let value = cx.flag_value::<T>();
+            if last_value.as_ref() == Some(&value) {
+                return;
+            }
+            last_value = Some(value.clone());
+            callback(value, cx);
         })
     }
 }

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

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<String, String>,
+}
+
+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<Value> = variants
+            .iter()
+            .map(|v| Value::String(v.override_key.to_string()))
+            .collect();
+        let enum_descriptions: Vec<Value> = 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."
+        }
+    })
+}

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<FeatureFlagVariant>,
+    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<String, String>,
+
+    _settings_subscription: Option<Subscription>,
+}
+
+impl FeatureFlagStore {
+    pub fn init(cx: &mut App) {
+        let subscription = cx.observe_global::<SettingsStore>(|cx| {
+            // Touch the global so anything observing `FeatureFlagStore` re-runs
+            cx.update_default_global::<FeatureFlagStore, _>(|_, _| {});
+        });
+
+        cx.update_default_global::<FeatureFlagStore, _>(|store, _| {
+            store._settings_subscription = Some(subscription);
+        });
+    }
+
+    pub fn known_flags() -> impl Iterator<Item = &'static FeatureFlagDescriptor> {
+        let mut seen = collections::HashSet::default();
+        inventory::iter::<FeatureFlagDescriptor>().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<String>) {
+        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<dyn Fs>, 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<dyn Fs>, 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<T: FeatureFlag>(&self, cx: &App) -> Option<T::Value> {
+        // `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::<T::Value>(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<T: FeatureFlag>(&self, cx: &App) -> bool {
+        self.try_flag_value::<T>(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<T: FeatureFlag>() -> bool {
+        if T::enabled_for_all() {
+            return true;
+        }
+        cfg!(debug_assertions) && T::enabled_for_staff() && !*ZED_DISABLE_STAFF
+    }
+}
+
+fn variant_from_key<V: FeatureFlagValue>(key: &str) -> Option<V> {
+    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::<FeatureFlagsSettings>();
+        });
+    }
+
+    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::<DemoFlag>(cx));
+        store.update_server_flags(false, vec!["demo".to_string()]);
+        assert!(store.has_flag::<DemoFlag>(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::<DemoFlag>(cx));
+        assert_eq!(
+            store.try_flag_value::<DemoFlag>(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::<IntensityFlag>(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::<EnumDemo>(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::<EnumDemo>(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::<DemoFlag>(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::<DemoFlag>(cx), None);
+        assert_eq!(PresenceFlag::default(), PresenceFlag::Off);
+    }
+}

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

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<TokenStream2> {
+    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<Self> {
+                ::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");
+    }
+}

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

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::<Vec<_>>();
 
-            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::DapRegistry, _>(|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)

crates/repl/src/notebook/notebook_ui.rs 🔗

@@ -68,8 +68,8 @@ pub fn init(cx: &mut App) {
     }
 
     cx.observe_flag::<NotebookFeatureFlag, _>({
-        move |is_enabled, cx| {
-            if is_enabled {
+        move |flag, cx| {
+            if *flag {
                 workspace::register_project_item::<NotebookEditor>(cx);
             } else {
                 // todo: there is no way to unregister a project item, so if the feature flag

crates/settings_content/src/settings_content.rs 🔗

@@ -211,6 +211,45 @@ pub struct SettingsContent {
     ///
     /// Default: 5
     pub modeline_lines: Option<usize>,
+
+    /// Local overrides for feature flags, keyed by flag name.
+    pub feature_flags: Option<FeatureFlagsMap>,
+}
+
+#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, MergeFrom)]
+#[serde(transparent)]
+pub struct FeatureFlagsMap(pub HashMap<String, String>);
+
+// A manual `JsonSchema` impl keeps this type's schema registered under a
+// unique name. The derived impl on a `#[serde(transparent)]` newtype around
+// `HashMap<String, String>` would inline to the map's own schema name (`Map_of_string`),
+// which is shared with every other `HashMap<String, String>` 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<String, String>;
+    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 {

crates/settings_ui/src/page_data.rs 🔗

@@ -62,7 +62,7 @@ macro_rules! concat_sections {
 }
 
 pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
-    vec![
+    let mut pages = vec![
         general_page(cx),
         appearance_page(),
         keymap_page(),
@@ -77,7 +77,32 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
         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 {

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

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<SettingsWindow>,
+) -> 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<SettingsWindow>,
+) -> AnyElement {
+    let forced_on = FeatureFlagStore::is_forced_on(descriptor);
+    let resolved = cx.global::<FeatureFlagStore>().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, <dyn Fs>::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<SettingsWindow>,
+) -> impl IntoElement {
+    let variants: Vec<FeatureFlagVariant> = (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(),
+                            <dyn Fs>::global(cx),
+                            cx,
+                        );
+                    }));
+            }
+            checkbox.into_any_element()
+        }
+    });
+
+    h_flex().gap_4().flex_wrap().children(row_items)
+}

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::<feature_flags::FeatureFlagStore>(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<SettingsWindow>) {
+        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<SettingsWindow>) {
         self.worktree_root_dirs.clear();

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

crates/sidebar/src/sidebar.rs 🔗

@@ -18,6 +18,9 @@ use agent_ui::{
 };
 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,
@@ -391,6 +394,30 @@ fn workspace_menu_worktree_labels(
         .collect()
 }
 
+fn apply_worktree_label_mode(
+    mut worktrees: Vec<ThreadItemWorktreeInfo>,
+    mode: AgentThreadWorktreeLabel,
+) -> Vec<ThreadItemWorktreeInfo> {
+    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`
@@ -454,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);
@@ -1246,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;
                         }
@@ -3835,6 +3867,11 @@ impl Sidebar {
 
         let is_remote = thread.workspace.is_remote(cx);
 
+        let worktrees = apply_worktree_label_mode(
+            thread.worktrees.clone(),
+            cx.flag_value::<AgentThreadWorktreeLabelFlag>(),
+        );
+
         ThreadItem::new(id, title)
             .base_bg(sidebar_bg)
             .icon(thread.icon)
@@ -3843,7 +3880,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)

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -449,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() {
@@ -3837,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;
@@ -3847,7 +3853,7 @@ 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("<none>");
                     panic!(
                         "unexpected sidebar thread while opening linked worktree thread: title=`{}`, worktree=`{}`",
@@ -10426,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]
@@ -10463,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]

crates/ui/src/components/ai/thread_item.rs 🔗

@@ -22,13 +22,13 @@ pub enum WorktreeKind {
     Linked,
 }
 
-#[derive(Clone)]
+#[derive(Clone, Default)]
 pub struct ThreadItemWorktreeInfo {
-    pub name: SharedString,
+    pub worktree_name: Option<SharedString>,
+    pub branch_name: Option<SharedString>,
     pub full_path: SharedString,
     pub highlight_positions: Vec<usize>,
     pub kind: WorktreeKind,
-    pub branch_name: Option<SharedString>,
 }
 
 #[derive(IntoElement, RegisterComponent)]
@@ -371,6 +371,7 @@ impl RenderOnce for ThreadItem {
             .worktrees
             .into_iter()
             .filter(|wt| wt.kind == WorktreeKind::Linked)
+            .filter(|wt| wt.worktree_name.is_some() || wt.branch_name.is_some())
             .collect();
 
         let has_worktree = !linked_worktrees.is_empty();
@@ -470,42 +471,68 @@ impl RenderOnce for ThreadItem {
                                 })
                                 .children(
                                     linked_worktrees.into_iter().map(|wt| {
-                                        let worktree_label = if wt.highlight_positions.is_empty() {
-                                            Label::new(wt.name)
+                                        let worktree_label = wt.worktree_name.clone().map(|name| {
+                                            if wt.highlight_positions.is_empty() {
+                                                Label::new(name)
+                                                    .size(LabelSize::Small)
+                                                    .color(Color::Muted)
+                                                    .truncate()
+                                                    .into_any_element()
+                                            } else {
+                                                HighlightedLabel::new(
+                                                    name,
+                                                    wt.highlight_positions.clone(),
+                                                )
                                                 .size(LabelSize::Small)
                                                 .color(Color::Muted)
                                                 .truncate()
                                                 .into_any_element()
+                                            }
+                                        });
+
+                                        // When only the branch is shown, lead with a branch icon;
+                                        // otherwise keep the worktree icon (which "covers" both the
+                                        // worktree and any accompanying branch).
+                                        let chip_icon = if wt.worktree_name.is_none()
+                                            && wt.branch_name.is_some()
+                                        {
+                                            IconName::GitBranch
                                         } else {
-                                            HighlightedLabel::new(wt.name, wt.highlight_positions)
+                                            IconName::GitWorktree
+                                        };
+
+                                        let branch_label = wt.branch_name.map(|branch| {
+                                            Label::new(branch)
                                                 .size(LabelSize::Small)
                                                 .color(Color::Muted)
                                                 .truncate()
                                                 .into_any_element()
-                                        };
+                                        });
+
+                                        let show_separator =
+                                            worktree_label.is_some() && branch_label.is_some();
 
                                         h_flex()
                                             .min_w_0()
                                             .gap_0p5()
                                             .child(
-                                                Icon::new(IconName::GitWorktree)
+                                                Icon::new(chip_icon)
                                                     .size(IconSize::XSmall)
                                                     .color(Color::Muted),
                                             )
-                                            .child(worktree_label)
-                                            .when_some(wt.branch_name, |this, branch| {
+                                            .when_some(worktree_label, |this, label| {
+                                                this.child(label)
+                                            })
+                                            .when(show_separator, |this| {
                                                 this.child(
                                                     Label::new("/")
                                                         .size(LabelSize::Small)
                                                         .color(separator_color)
                                                         .flex_shrink_0(),
                                                 )
-                                                .child(
-                                                    Label::new(branch)
-                                                        .size(LabelSize::Small)
-                                                        .color(Color::Muted)
-                                                        .truncate(),
-                                                )
+                                            })
+                                            .when_some(branch_label, |this, label| {
+                                                this.child(label)
                                             })
                                     }),
                                 )
@@ -628,7 +655,7 @@ impl Component for ThreadItem {
                             .icon(IconName::AiClaude)
                             .timestamp("2w")
                             .worktrees(vec![ThreadItemWorktreeInfo {
-                                name: "link-agent-panel".into(),
+                                worktree_name: Some("link-agent-panel".into()),
                                 full_path: "link-agent-panel".into(),
                                 highlight_positions: Vec::new(),
                                 kind: WorktreeKind::Linked,
@@ -656,7 +683,7 @@ impl Component for ThreadItem {
                         ThreadItem::new("ti-5b", "Full metadata example")
                             .icon(IconName::AiClaude)
                             .worktrees(vec![ThreadItemWorktreeInfo {
-                                name: "my-project".into(),
+                                worktree_name: Some("my-project".into()),
                                 full_path: "my-project".into(),
                                 highlight_positions: Vec::new(),
                                 kind: WorktreeKind::Linked,
@@ -675,7 +702,7 @@ impl Component for ThreadItem {
                         ThreadItem::new("ti-5c", "Full metadata with branch")
                             .icon(IconName::AiClaude)
                             .worktrees(vec![ThreadItemWorktreeInfo {
-                                name: "my-project".into(),
+                                worktree_name: Some("my-project".into()),
                                 full_path: "/worktrees/my-project/zed".into(),
                                 highlight_positions: Vec::new(),
                                 kind: WorktreeKind::Linked,
@@ -694,7 +721,7 @@ impl Component for ThreadItem {
                         ThreadItem::new("ti-5d", "Metadata overflow with long branch name")
                             .icon(IconName::AiClaude)
                             .worktrees(vec![ThreadItemWorktreeInfo {
-                                name: "my-project".into(),
+                                worktree_name: Some("my-project".into()),
                                 full_path: "/worktrees/my-project/zed".into(),
                                 highlight_positions: Vec::new(),
                                 kind: WorktreeKind::Linked,
@@ -713,7 +740,7 @@ impl Component for ThreadItem {
                         ThreadItem::new("ti-5e", "Main worktree branch with diff stats")
                             .icon(IconName::ZedAgent)
                             .worktrees(vec![ThreadItemWorktreeInfo {
-                                name: "zed".into(),
+                                worktree_name: Some("zed".into()),
                                 full_path: "/projects/zed".into(),
                                 highlight_positions: Vec::new(),
                                 kind: WorktreeKind::Main,
@@ -732,7 +759,9 @@ impl Component for ThreadItem {
                         ThreadItem::new("ti-5f", "Thread with a very long worktree name")
                             .icon(IconName::AiClaude)
                             .worktrees(vec![ThreadItemWorktreeInfo {
-                                name: "very-long-worktree-name-that-should-truncate".into(),
+                                worktree_name: Some(
+                                    "very-long-worktree-name-that-should-truncate".into(),
+                                ),
                                 full_path: "/worktrees/very-long-worktree-name/zed".into(),
                                 highlight_positions: Vec::new(),
                                 kind: WorktreeKind::Linked,
@@ -749,7 +778,7 @@ impl Component for ThreadItem {
                         ThreadItem::new("ti-5g", "Filtered thread with highlighted worktree")
                             .icon(IconName::AiClaude)
                             .worktrees(vec![ThreadItemWorktreeInfo {
-                                name: "jade-glen".into(),
+                                worktree_name: Some("jade-glen".into()),
                                 full_path: "/worktrees/jade-glen/zed".into(),
                                 highlight_positions: vec![0, 1, 2, 3],
                                 kind: WorktreeKind::Linked,
@@ -767,14 +796,14 @@ impl Component for ThreadItem {
                             .icon(IconName::AiClaude)
                             .worktrees(vec![
                                 ThreadItemWorktreeInfo {
-                                    name: "jade-glen".into(),
+                                    worktree_name: Some("jade-glen".into()),
                                     full_path: "/worktrees/jade-glen/zed".into(),
                                     highlight_positions: Vec::new(),
                                     kind: WorktreeKind::Linked,
                                     branch_name: None,
                                 },
                                 ThreadItemWorktreeInfo {
-                                    name: "fawn-otter".into(),
+                                    worktree_name: Some("fawn-otter".into()),
                                     full_path: "/worktrees/fawn-otter/zed-slides".into(),
                                     highlight_positions: Vec::new(),
                                     kind: WorktreeKind::Linked,
@@ -793,14 +822,14 @@ impl Component for ThreadItem {
                             .icon(IconName::ZedAgent)
                             .worktrees(vec![
                                 ThreadItemWorktreeInfo {
-                                    name: "jade-glen".into(),
+                                    worktree_name: Some("jade-glen".into()),
                                     full_path: "/worktrees/jade-glen/zed".into(),
                                     highlight_positions: Vec::new(),
                                     kind: WorktreeKind::Linked,
                                     branch_name: Some("fix".into()),
                                 },
                                 ThreadItemWorktreeInfo {
-                                    name: "fawn-otter".into(),
+                                    worktree_name: Some("fawn-otter".into()),
                                     full_path: "/worktrees/fawn-otter/zed-slides".into(),
                                     highlight_positions: Vec::new(),
                                     kind: WorktreeKind::Linked,
@@ -819,7 +848,7 @@ impl Component for ThreadItem {
                             .icon(IconName::AiClaude)
                             .project_name("my-remote-server")
                             .worktrees(vec![ThreadItemWorktreeInfo {
-                                name: "jade-glen".into(),
+                                worktree_name: Some("jade-glen".into()),
                                 full_path: "/worktrees/jade-glen/zed".into(),
                                 highlight_positions: Vec::new(),
                                 kind: WorktreeKind::Linked,
@@ -840,7 +869,7 @@ impl Component for ThreadItem {
                                 PathBuf::from("/projects/zed-slides"),
                             ]))
                             .worktrees(vec![ThreadItemWorktreeInfo {
-                                name: "jade-glen".into(),
+                                worktree_name: Some("jade-glen".into()),
                                 full_path: "/worktrees/jade-glen/zed".into(),
                                 highlight_positions: Vec::new(),
                                 kind: WorktreeKind::Linked,
@@ -858,7 +887,7 @@ impl Component for ThreadItem {
                             .icon(IconName::ZedAgent)
                             .project_name("remote-dev")
                             .worktrees(vec![ThreadItemWorktreeInfo {
-                                name: "my-worktree".into(),
+                                worktree_name: Some("my-worktree".into()),
                                 full_path: "/worktrees/my-worktree/zed".into(),
                                 highlight_positions: Vec::new(),
                                 kind: WorktreeKind::Linked,

crates/zed/src/main.rs 🔗

@@ -555,6 +555,7 @@ fn main() {
         debugger_ui::init(cx);
         debugger_tools::init(cx);
         client::init(&client, cx);
+        feature_flags::FeatureFlagStore::init(cx);
 
         let system_id = cx.foreground_executor().block_on(system_id).ok();
         let installation_id = cx.foreground_executor().block_on(installation_id).ok();

crates/zed/src/visual_test_runner.rs 🔗

@@ -2914,7 +2914,7 @@ impl gpui::Render for ThreadItemBranchNameTestView {
                         .icon(IconName::AiClaude)
                         .timestamp("5m")
                         .worktrees(vec![ThreadItemWorktreeInfo {
-                            name: "jade-glen".into(),
+                            worktree_name: Some("jade-glen".into()),
                             full_path: "/worktrees/jade-glen/zed".into(),
                             highlight_positions: Vec::new(),
                             kind: WorktreeKind::Linked,
@@ -2931,7 +2931,7 @@ impl gpui::Render for ThreadItemBranchNameTestView {
                         .icon(IconName::AiClaude)
                         .timestamp("1h")
                         .worktrees(vec![ThreadItemWorktreeInfo {
-                            name: "focal-arrow".into(),
+                            worktree_name: Some("focal-arrow".into()),
                             full_path: "/worktrees/focal-arrow/zed".into(),
                             highlight_positions: Vec::new(),
                             kind: WorktreeKind::Linked,
@@ -2946,7 +2946,7 @@ impl gpui::Render for ThreadItemBranchNameTestView {
                         .icon(IconName::ZedAgent)
                         .timestamp("2d")
                         .worktrees(vec![ThreadItemWorktreeInfo {
-                            name: "zed".into(),
+                            worktree_name: Some("zed".into()),
                             full_path: "/projects/zed".into(),
                             highlight_positions: Vec::new(),
                             kind: WorktreeKind::Main,
@@ -2963,7 +2963,7 @@ impl gpui::Render for ThreadItemBranchNameTestView {
                         .icon(IconName::ZedAgent)
                         .timestamp("3d")
                         .worktrees(vec![ThreadItemWorktreeInfo {
-                            name: "zed".into(),
+                            worktree_name: Some("zed".into()),
                             full_path: "/projects/zed".into(),
                             highlight_positions: Vec::new(),
                             kind: WorktreeKind::Main,
@@ -2978,7 +2978,7 @@ impl gpui::Render for ThreadItemBranchNameTestView {
                         .icon(IconName::AiClaude)
                         .timestamp("6d")
                         .worktrees(vec![ThreadItemWorktreeInfo {
-                            name: "stoic-reed".into(),
+                            worktree_name: Some("stoic-reed".into()),
                             full_path: "/worktrees/stoic-reed/zed".into(),
                             highlight_positions: Vec::new(),
                             kind: WorktreeKind::Linked,
@@ -2995,7 +2995,7 @@ impl gpui::Render for ThreadItemBranchNameTestView {
                         .icon(IconName::ZedAgent)
                         .timestamp("40m")
                         .worktrees(vec![ThreadItemWorktreeInfo {
-                            name: "focal-arrow".into(),
+                            worktree_name: Some("focal-arrow".into()),
                             full_path: "/worktrees/focal-arrow/zed".into(),
                             highlight_positions: Vec::new(),
                             kind: WorktreeKind::Linked,
@@ -3014,7 +3014,7 @@ impl gpui::Render for ThreadItemBranchNameTestView {
                         .added(42)
                         .removed(17)
                         .worktrees(vec![ThreadItemWorktreeInfo {
-                            name: "jade-glen".into(),
+                            worktree_name: Some("jade-glen".into()),
                             full_path: "/worktrees/jade-glen/zed".into(),
                             highlight_positions: Vec::new(),
                             kind: WorktreeKind::Linked,
@@ -3031,7 +3031,7 @@ impl gpui::Render for ThreadItemBranchNameTestView {
                         .added(108)
                         .removed(53)
                         .worktrees(vec![ThreadItemWorktreeInfo {
-                            name: "my-project".into(),
+                            worktree_name: Some("my-project".into()),
                             full_path: "/worktrees/my-project/zed".into(),
                             highlight_positions: Vec::new(),
                             kind: WorktreeKind::Linked,
@@ -3052,7 +3052,7 @@ impl gpui::Render for ThreadItemBranchNameTestView {
                         .added(23)
                         .removed(8)
                         .worktrees(vec![ThreadItemWorktreeInfo {
-                            name: "zed".into(),
+                            worktree_name: Some("zed".into()),
                             full_path: "/projects/zed".into(),
                             highlight_positions: Vec::new(),
                             kind: WorktreeKind::Main,

crates/zed/src/zed.rs 🔗

@@ -159,8 +159,8 @@ pub fn init(cx: &mut App) {
 
     cx.observe_flag::<PanicFeatureFlag, _>({
         let mut added = false;
-        move |enabled, cx| {
-            if added || !enabled {
+        move |flag, cx| {
+            if added || !*flag {
                 return;
             }
             added = true;