diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
index 6838c30..54a4bd1 100644
--- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
+++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
@@ -95,13 +95,7 @@
+ CommandParameter="{Binding}">
@@ -120,13 +114,7 @@
+ CommandParameter="{Binding}">
@@ -159,13 +147,7 @@
+ CommandParameter="{Binding}">
diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs
index 0bdd650..464b377 100644
--- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs
@@ -1,28 +1,43 @@
+using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
+using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
+using ClaudeDo.Ui.Views.Controls;
+using ClaudeDo.Ui.Views.MissionControl;
using ClaudeDo.Ui.Views.Modals;
namespace ClaudeDo.Ui.Views.Islands;
public partial class TasksIslandView : UserControl
{
- // Public so the Mission Control window can accept the same drag payload (drop-to-queue).
- public static readonly DataFormat TaskRowFormat =
- DataFormat.CreateStringApplicationFormat("claudedo-task-row");
+ private readonly TaskDragController _drag = new();
+
+ // Custom-drag gesture state. The drag is ARMED on press and BEGINS once the pointer moves
+ // past the threshold, so a plain click still selects the row.
+ private const double DragThreshold = 4;
+ private Point _pressPoint;
+ private TaskRowViewModel? _pressRow;
+ private Control? _pressControl;
+ private bool _dragArmed;
+ private bool _dragging;
public TasksIslandView()
{
InitializeComponent();
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
+ AddHandler(PointerMovedEvent, OnPointerMovedDrag, RoutingStrategies.Tunnel);
+ AddHandler(PointerReleasedEvent, OnPointerReleasedDrag, RoutingStrategies.Tunnel);
+ AddHandler(PointerCaptureLostEvent, OnPointerCaptureLost);
DataContextChanged += (_, _) =>
{
if (DataContext is TasksIslandViewModel vm)
@@ -103,9 +118,15 @@ public partial class TasksIslandView : UserControl
return await tcs.Task;
}
- private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
+ // ── Custom ghost drag ────────────────────────────────────────────────────
+ // Replaces both the OLE DoDragDropAsync reorder and the OLE drop-to-queue path: a hand-built
+ // drag (pointer capture + a transparent topmost ghost window) is the only way to get a
+ // translucent follower that crosses from this window into the separate Mission Control window.
+
+ private void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
{
- if (DataContext is not TasksIslandViewModel vm) return;
+ ResetPressState();
+ if (DataContext is not TasksIslandViewModel) return;
if (e.Source is not Visual src) return;
var button = src as Button ?? src.FindAncestorOfType();
@@ -113,8 +134,7 @@ public partial class TasksIslandView : UserControl
if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return;
// Select now so the details pane updates whether the gesture becomes a click or a drag.
- // (Button.Click doesn't fire once DoDragDropAsync captures the pointer.)
- vm.SelectedTask = row;
+ if (DataContext is TasksIslandViewModel vm) vm.SelectedTask = row;
// If the click landed on a nested Button (e.g. the done-toggle checkbox or star),
// don't start a drag — that would capture the pointer and swallow the inner Click.
@@ -122,79 +142,171 @@ public partial class TasksIslandView : UserControl
&& parentVisual.FindAncestorOfType() is not null;
if (nestedInsideButton) return;
- if (!vm.CanReorder || row.IsRunning) return;
+ // Running tasks can be neither reordered nor re-queued.
+ if (row.IsRunning) return;
- var data = new DataTransfer();
- data.Add(DataTransferItem.Create(TaskRowFormat, row.Id));
- try
+ // Arm the drag for ANY list kind so drag-to-queue works everywhere; reorder-on-drop is
+ // still gated on CanReorder (user lists only).
+ _pressPoint = e.GetPosition(this);
+ _pressRow = row;
+ _pressControl = button;
+ _dragArmed = true;
+ }
+
+ private void OnPointerMovedDrag(object? sender, PointerEventArgs e)
+ {
+ if (!_dragArmed && !_dragging) return;
+ if (TopLevel.GetTopLevel(this) is not { } topLevel) return;
+
+ if (_dragArmed && !_dragging)
{
- await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Move);
+ var p = e.GetPosition(this);
+ if (Math.Abs(p.X - _pressPoint.X) < DragThreshold && Math.Abs(p.Y - _pressPoint.Y) < DragThreshold)
+ return;
+ BeginDrag(e, topLevel);
}
- finally
+
+ if (_dragging)
{
- vm.ClearDropHints();
+ _drag.MoveTo(this.PointToScreen(e.GetPosition(this)));
+ UpdateReorderHint(e, topLevel);
}
}
- private void OnRowPointerPressed(object? sender, PointerPressedEventArgs e) { }
- private void OnRowPointerMoved(object? sender, PointerEventArgs e) { }
- private void OnRowPointerReleased(object? sender, PointerReleasedEventArgs e) { }
-
- private void OnRowDragOver(object? sender, DragEventArgs e)
+ private void BeginDrag(PointerEventArgs e, TopLevel topLevel)
{
- if (DataContext is not TasksIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
- if (!e.DataTransfer?.Contains(TaskRowFormat) ?? true)
+ if (_pressControl is null || _pressRow is null) return;
+ // Snapshot the row BEFORE applying the "grabbed" style so the ghost stays crisp.
+ _drag.Begin(_pressControl, e.GetPosition(_pressControl), topLevel.RenderScaling);
+ _pressRow.IsDragging = true;
+ _dragging = true;
+ e.Pointer.Capture(this);
+ }
+
+ private async void OnPointerReleasedDrag(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_dragArmed && !_dragging) return;
+
+ var wasDragging = _dragging;
+ var row = _pressRow;
+ var topLevel = TopLevel.GetTopLevel(this);
+ var screen = wasDragging && topLevel is not null
+ ? this.PointToScreen(e.GetPosition(this))
+ : default;
+
+ EndDrag(e);
+
+ if (!wasDragging || row is null || topLevel is null) return;
+
+ // 1) Released over the Mission Control window → queue the task.
+ if (MissionControlUnder(screen) is { } mc)
{
- e.DragEffects = DragDropEffects.None;
- vm.ClearDropHints();
+ await mc.EnqueueTaskAsync(row.Id);
return;
}
- if (sender is not Button b || b.DataContext is not TaskRowViewModel target || target.IsRunning)
+
+ // 2) Released over another row in the same user list → reorder.
+ if (DataContext is TasksIslandViewModel vm && vm.CanReorder)
+ {
+ var targetButton = RowButtonAt(e, topLevel);
+ if (targetButton?.DataContext is TaskRowViewModel target
+ && !ReferenceEquals(target, row) && !target.IsRunning)
+ {
+ var placeBelow = e.GetPosition(targetButton).Y > targetButton.Bounds.Height / 2;
+ await vm.ReorderAsync(row, target, placeBelow);
+ return;
+ }
+ }
+
+ // 3) Anywhere else → cancel; EndDrag already restored the source row.
+ }
+
+ private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
+ {
+ // We just took capture ourselves (stealing it from the row Button when the drag began) —
+ // that is not a real loss, so don't tear the drag down.
+ if (ReferenceEquals(e.Pointer.Captured, this)) return;
+ if (!_dragArmed && !_dragging) return;
+ if (_pressRow is not null) _pressRow.IsDragging = false;
+ if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints();
+ _drag.End();
+ ResetPressState();
+ }
+
+ private void EndDrag(PointerEventArgs e)
+ {
+ if (_pressRow is not null) _pressRow.IsDragging = false;
+ if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints();
+ _drag.End();
+ if (_dragging) e.Pointer.Capture(null);
+ ResetPressState();
+ }
+
+ private void ResetPressState()
+ {
+ _dragArmed = false;
+ _dragging = false;
+ _pressRow = null;
+ _pressControl = null;
+ }
+
+ // Live drop-hint while dragging over rows in the source (user) list.
+ private void UpdateReorderHint(PointerEventArgs e, TopLevel topLevel)
+ {
+ if (DataContext is not TasksIslandViewModel vm) return;
+ if (!vm.CanReorder) { vm.ClearDropHints(); return; }
+
+ var targetButton = RowButtonAt(e, topLevel);
+ if (targetButton?.DataContext is not TaskRowViewModel target
+ || target.IsRunning || ReferenceEquals(target, _pressRow))
{
- e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
- var sourceId = e.DataTransfer?.TryGetValue(TaskRowFormat);
- if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id)
- {
- e.DragEffects = DragDropEffects.None;
- vm.ClearDropHints();
- return;
- }
+ var placeBelow = e.GetPosition(targetButton).Y > targetButton.Bounds.Height / 2;
- var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
-
- // Canonicalize: "drop below X" == "drop above X+1". Render the indicator
- // above X+1 when there is one; only the last row in a section shows a below-line.
+ // Canonicalize: "drop below X" == "drop above X+1". Render the indicator above X+1 when
+ // there is one; only the last row in a section shows a below-line.
TaskRowViewModel hintRow = target;
bool hintBelow = false;
if (placeBelow)
{
var next = FindNextInSameSection(vm, target);
- if (next is not null && !next.IsRunning)
- {
- hintRow = next;
- hintBelow = false;
- }
- else
- {
- hintRow = target;
- hintBelow = true;
- }
+ if (next is not null && !next.IsRunning) { hintRow = next; hintBelow = false; }
+ else { hintRow = target; hintBelow = true; }
}
// A hint that lands right where the dragged row already sits is a no-op.
- if (hintRow.Id == sourceId)
- {
- e.DragEffects = DragDropEffects.None;
- vm.ClearDropHints();
- return;
- }
+ if (_pressRow is not null && hintRow.Id == _pressRow.Id) { vm.ClearDropHints(); return; }
vm.SetDropHint(hintRow, hintBelow);
- e.DragEffects = DragDropEffects.Move;
+ }
+
+ // The row-level Button under the cursor, found by geometric hit-test on the source window
+ // (works while the pointer is captured to this control).
+ private static Button? RowButtonAt(PointerEventArgs e, TopLevel topLevel)
+ {
+ var pt = e.GetPosition((Visual)topLevel);
+ if (topLevel.InputHitTest(pt) is not Visual hit) return null;
+ var button = hit as Button ?? hit.FindAncestorOfType();
+ while (button is not null && button.DataContext is not TaskRowViewModel)
+ button = (button.Parent as Visual)?.FindAncestorOfType();
+ return button?.DataContext is TaskRowViewModel ? button : null;
+ }
+
+ // The Mission Control view model whose window contains the release point, if any.
+ private static MissionControlViewModel? MissionControlUnder(PixelPoint screen)
+ {
+ if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
+ return null;
+ foreach (var w in desktop.Windows)
+ {
+ if (w is not MissionControlWindow mc || !mc.IsVisible) continue;
+ if (DragHitTest.WindowContains(mc.Position, mc.ClientSize, mc.RenderScaling, screen))
+ return mc.DataContext as MissionControlViewModel;
+ }
+ return null;
}
private static TaskRowViewModel? FindNextInSameSection(TasksIslandViewModel vm, TaskRowViewModel row)
@@ -206,41 +318,4 @@ public partial class TasksIslandView : UserControl
}
return null;
}
-
- private async void OnRowDrop(object? sender, DragEventArgs e)
- {
- if (DataContext is not TasksIslandViewModel vm) return;
- try
- {
- if (sender is not Button b || b.DataContext is not TaskRowViewModel target) return;
- if (target.IsRunning) return;
-
- var sourceId = e.DataTransfer?.TryGetValue(TaskRowFormat);
- if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id) return;
-
- var source = FindRowById(vm, sourceId);
- if (source is null || source.IsRunning) return;
-
- var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
-
- // Clear the 6px drop-hint spacer BEFORE the move so the reorder animates
- // into its truly-final layout in one step (otherwise the row lands in the
- // gap, then the gap collapses and everything shifts up a second time).
- vm.ClearDropHints();
-
- await vm.ReorderAsync(source, target, placeBelow);
- }
- catch
- {
- vm.ClearDropHints();
- throw;
- }
- }
-
- private static TaskRowViewModel? FindRowById(TasksIslandViewModel vm, string id)
- {
- foreach (var r in vm.Items)
- if (r.Id == id) return r;
- return null;
- }
}
diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs
index 681fda2..7665eb7 100644
--- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs
@@ -5,7 +5,6 @@ using Avalonia.Interactivity;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
-using ClaudeDo.Ui.Views.Islands;
namespace ClaudeDo.Ui.Views.MissionControl;
@@ -25,9 +24,7 @@ public partial class MissionControlView : UserControl
private void OnPaneDragOver(object? sender, DragEventArgs e)
{
var dt = e.DataTransfer;
- var accept = (dt?.Contains(PaneFormat) ?? false)
- || (dt?.Contains(TasksIslandView.TaskRowFormat) ?? false);
- e.DragEffects = accept ? DragDropEffects.Move : DragDropEffects.None;
+ e.DragEffects = (dt?.Contains(PaneFormat) ?? false) ? DragDropEffects.Move : DragDropEffects.None;
}
private void OnPaneDrop(object? sender, DragEventArgs e)
@@ -36,15 +33,8 @@ public partial class MissionControlView : UserControl
var dt = e.DataTransfer;
if (dt is null) return;
- // A task dragged from the main app → queue it.
- var taskId = dt.TryGetValue(TasksIslandView.TaskRowFormat);
- if (!string.IsNullOrEmpty(taskId))
- {
- _ = vm.EnqueueTaskAsync(taskId);
- return;
- }
-
- // A pane dragged within the grid → reorder.
+ // A pane dragged within the grid → reorder. (Drag-to-queue from the main app now arrives
+ // via the custom ghost drag's screen hit-test, not an OLE drop.)
var draggedId = dt.TryGetValue(PaneFormat);
if (string.IsNullOrEmpty(draggedId)) return;
if (e.Source is not Avalonia.Visual src) return;