diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs
--- a/crates/ui/src/docking/model.rs
+++ b/crates/ui/src/docking/model.rs
@@ -287,6 +287,12 @@ impl<P: Clone + Eq + Hash> Layout<P> {
         self.panels().contains(&panel)
     }
 
+    /// The panels `panel` can join as a tab: every other panel whose permissions accept tabs.
+    /// A "Move to group" menu with an empty answer has nothing to offer and stays hidden.
+    pub fn move_targets(&self, panel: &P, permissions: impl Fn(&P) -> Permissions) -> Vec<&P> {
+        self.panels().into_iter().filter(|target| *target != panel && permissions(target).accepts_tabs).collect()
+    }
+
     /// Capture a panel's nearest surviving group/split neighbor before hiding it.
     pub fn location(&self, panel: &P) -> Result<Location<P>, DockError> {
         self.validate()?;
diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs
--- a/crates/ui/src/docking/surface.rs
+++ b/crates/ui/src/docking/surface.rs
@@ -705,12 +705,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
                     ui.close();
                 }
             }
-            if policy.movable {
+            if policy.movable && !self.layout.move_targets(panel, self.permissions).is_empty() {
                 let text = self.content.action_label(ui, &self.style.move_label, &self.style.font, self.style.text);
                 let response = ui.menu_button(text, |ui| {
                     egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
-                        for target in self.layout.panels() {
-                            if target != panel && (self.permissions)(target).accepts_tabs && self.menu_label(ui, target).clicked() {
+                        for target in self.layout.move_targets(panel, self.permissions) {
+                            if self.menu_label(ui, target).clicked() {
                                 self.output.actions.push(Action::Move {
                                     panel: panel.clone(),
                                     anchor: target.clone(),
diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs
--- a/crates/ui/src/docking/tests.rs
+++ b/crates/ui/src/docking/tests.rs
@@ -51,6 +51,22 @@ fn detach_redock_merge_and_collapse_empty_source_containers() {
     assert_eq!(layout.panels().len(), 6);
 }
 
+#[test]
+fn move_targets_skip_the_panel_itself_and_protected_anchors() {
+    let layout = sample();
+    let all = |_: &u32| Permissions::default();
+    let targets = layout.move_targets(&1, all);
+    assert_eq!(targets.len(), 5);
+    assert!(!targets.contains(&&1));
+    let guarded = |p: &u32| if *p == 3 { Permissions::PROTECTED } else { Permissions::default() };
+    assert!(!layout.move_targets(&1, guarded).contains(&&3));
+
+    // A lone panel has nowhere to go — its "Move to group" menu hides
+    // (storytold/lightcraft#1768).
+    let single = Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() };
+    assert!(single.move_targets(&1, all).is_empty());
+}
+
 #[test]
 fn invalid_moves_are_atomic_including_after_a_source_would_have_been_removed() {
     let layout = sample();
