diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
index 1618412e..c931d38d 100644
--- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
@@ -729,7 +729,18 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
pane.Dispose();
}
- // Mirrors ConPtySessions' add/remove 1:1 into Panes (typed as the pane abstraction).
+ /// Swaps two tiles' slots (overview-grid drag and drop); also reorders the focus-mode tabs.
+ public void SwapPanes(ConPtyPaneViewModel a, ConPtyPaneViewModel b)
+ {
+ var i = ConPtySessions.IndexOf(a);
+ var j = ConPtySessions.IndexOf(b);
+ if (i < 0 || j < 0 || i == j) return;
+ if (i > j) (i, j) = (j, i);
+ ConPtySessions.Move(i, j);
+ ConPtySessions.Move(j - 1, i);
+ }
+
+ // Mirrors ConPtySessions' add/remove/move 1:1 into Panes (typed as the pane abstraction).
private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
@@ -740,6 +751,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
case NotifyCollectionChangedAction.Remove:
Panes.RemoveAt(e.OldStartingIndex);
break;
+ case NotifyCollectionChangedAction.Move:
+ Panes.Move(e.OldStartingIndex, e.NewStartingIndex);
+ break;
default: // Reset
Panes.Clear();
break;
diff --git a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml
index 0ad5f5c7..fcc665d1 100644
--- a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml
+++ b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml
@@ -14,8 +14,8 @@
-
-
+
diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml
index 8257f51b..1258bcbb 100644
--- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml
+++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml
@@ -16,6 +16,16 @@
+
+
+
+
diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs
index 5ddca48f..cd9cd5e5 100644
--- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs
@@ -4,12 +4,14 @@ using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
+using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.VisualTree;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels;
+using ClaudeDo.Ui.ViewModels.MissionControl;
namespace ClaudeDo.Ui.Views.MissionControl;
@@ -33,6 +35,70 @@ public partial class MissionControlView : UserControl
// Button runs its Command, and a synchronous Hide unbinds that Command first.
RecentSessionsList.AddHandler(Button.ClickEvent,
(_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(() => RecentSessionsButton.Flyout?.Hide()));
+ OverviewGrid.AddHandler(PointerPressedEvent, OnTilePointerPressed);
+ OverviewGrid.AddHandler(PointerMovedEvent, OnTilePointerMoved);
+ OverviewGrid.AddHandler(PointerReleasedEvent, OnTilePointerReleased);
+ }
+
+ private const double DragThreshold = 4;
+
+ /// Tile presenter whose header was pressed; null when no tile drag is pending.
+ private ContentControl? _dragSource;
+ private Point _dragStart;
+ private bool _dragging;
+ private Border? _dropHint;
+
+ // Buttons in the header mark PointerPressed handled, so only the bare header starts a drag.
+ private void OnTilePointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (!e.GetCurrentPoint(OverviewGrid).Properties.IsLeftButtonPressed || e.Source is not Visual source) return;
+ var chain = source.GetSelfAndVisualAncestors().ToList();
+ if (!chain.OfType().Any(b => b.Classes.Contains("pane-header"))) return;
+ _dragSource = chain.OfType().FirstOrDefault(c => ReferenceEquals(c.Parent, OverviewGrid));
+ _dragStart = e.GetPosition(OverviewGrid);
+ }
+
+ private void OnTilePointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (_dragSource is null) return;
+ if (!e.GetCurrentPoint(OverviewGrid).Properties.IsLeftButtonPressed) { EndTileDrag(); return; }
+
+ var pos = e.GetPosition(OverviewGrid);
+ if (!_dragging)
+ {
+ if (Math.Abs(pos.X - _dragStart.X) < DragThreshold && Math.Abs(pos.Y - _dragStart.Y) < DragThreshold) return;
+ _dragging = true;
+ _dragSource.Opacity = 0.5;
+ _dropHint = new Border { Classes = { "pane-drop-hint" }, IsVisible = false };
+ OverviewGrid.Children.Add(_dropHint);
+ }
+
+ var target = TileAt(pos);
+ _dropHint!.IsVisible = target is not null;
+ if (target is null) return;
+ Grid.SetColumn(_dropHint, Grid.GetColumn(target));
+ Grid.SetRow(_dropHint, Grid.GetRow(target));
+ }
+
+ private void OnTilePointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ var source = _dragSource;
+ var target = _dragging ? TileAt(e.GetPosition(OverviewGrid)) : null;
+ EndTileDrag();
+ if (source?.Content is ConPtyPaneViewModel a && target?.Content is ConPtyPaneViewModel b)
+ _vm?.SwapPanes(a, b);
+ }
+
+ private ContentControl? TileAt(Point pos) => OverviewGrid.Children.OfType()
+ .FirstOrDefault(c => !ReferenceEquals(c, _dragSource) && c.Bounds.Contains(pos));
+
+ private void EndTileDrag()
+ {
+ if (_dragSource is not null) _dragSource.Opacity = 1;
+ if (_dropHint is not null) OverviewGrid.Children.Remove(_dropHint);
+ _dragSource = null;
+ _dropHint = null;
+ _dragging = false;
}
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs
index 686cbcd3..57279f33 100644
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs
@@ -491,6 +491,22 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(2, vm.ColumnCount);
}
+ [Fact]
+ public async Task SwapPanes_SwapsSlots_InBothCollections()
+ {
+ var worker = new FakeWorker();
+ using var vm = BuildVm(worker);
+ await vm.OpenConPtySessionAsync("t1");
+ await vm.OpenConPtySessionAsync("t2");
+ await vm.OpenConPtySessionAsync("t3");
+ var (a, b, c) = (vm.Panes[0], vm.Panes[1], vm.Panes[2]);
+
+ vm.SwapPanes(c, a);
+
+ Assert.Equal(new[] { c, b, a }, vm.Panes);
+ Assert.Equal(new[] { c, b, a }, vm.ConPtySessions);
+ }
+
[Fact]
public async Task SetColumns_OverridesAutoCapsAtPaneCountAndPersists()
{