feat(ui): move a task to another list via drag & drop

Dragging a task row onto a user list in the Lists island now reassigns it
to that list. The task drag holds the pointer capture, so the release is
resolved geometrically (new case between the Mission Control and reorder
cases) instead of going through the Lists island's own DragDrop path,
which never sees a DragEventArgs during a task drag.

TaskRepository.MoveToListAsync reassigns the task plus every descendant
(a child must never sit in a different list than its parent) and appends
the task at the end of the target list. Guards: running tasks and tasks
holding an Active/Kept worktree are rejected to the footer error strip;
a move that changes repo asks for confirmation naming both repos.

The source repo is read from the task's own list rather than the island's
current list, which is a smart/virtual list with no working dir of its own
whenever one of those is shown.
This commit is contained in:
Mika Kuns
2026-07-29 15:10:59 +02:00
parent 4dc4fe27e3
commit 92ce7a4a77
11 changed files with 690 additions and 4 deletions
@@ -693,6 +693,10 @@
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="Transparent" />
<!-- Reserved so the task-drop highlight below only swaps the brush — a thickness that changes
on hover would nudge the row's content while dragging over it. -->
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="Border.list-item:pointerover">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
@@ -700,6 +704,11 @@
<Style Selector="Border.list-item.active">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
</Style>
<!-- Highlighted while a dragged task hovers over this row as a move target. -->
<Style Selector="Border.list-item.drop-target">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Style>
<!-- Active item text / icon colors -->
<Style Selector="Border.list-item.active TextBlock.list-label">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
@@ -16,6 +16,9 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
[ObservableProperty] private bool _isManual;
[ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow;
// Set while a dragged task hovers over this row as a move target — distinct from
// DropHintAbove/Below, which mark an insertion point for list reordering.
[ObservableProperty] private bool _isTaskDropTarget;
public string? IconKey { get; init; }
public string? DotColorKey { get; init; }
}
@@ -471,6 +471,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
public bool CanReorder => _currentList?.Kind == ListKind.User;
public string? CurrentListId => _currentList?.Id;
/// <summary>Set by the shell (mirrors <see cref="ListsIslandViewModel.Dialogs"/>) so a
/// cross-repo move can confirm via the shared modal seam.</summary>
public IDialogService? Dialogs { get; set; }
public void ClearDropHints()
{
@@ -573,6 +578,76 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
return null;
}
/// <summary>
/// Drag-drop move: reassigns a task (and every descendant) to <paramref name="targetList"/>.
/// No-op for anything that isn't a user list the task doesn't already sit in.
/// Rejects running tasks and tasks (or descendants) holding an Active/Kept worktree — the
/// worktree would keep pointing at the source repo. A move across repos without a worktree
/// is allowed but asks for confirmation first.
/// </summary>
public async Task MoveTaskToListAsync(TaskRowViewModel row, ListNavItemViewModel targetList)
{
if (targetList.Kind != ListKind.User) return;
var targetListId = targetList.Id.StartsWith("user:", StringComparison.Ordinal)
? targetList.Id["user:".Length..]
: targetList.Id;
await using var db = await _dbFactory.CreateDbContextAsync();
// The row's own list, not _currentList — the task island also shows smart/virtual lists,
// whose rows come from many lists and which carry no working dir of their own. Dropping a
// task on the list it already sits in is a silent no-op.
var sourceListId = await db.Tasks.AsNoTracking()
.Where(t => t.Id == row.Id).Select(t => t.ListId).FirstOrDefaultAsync();
if (sourceListId is null || sourceListId == targetListId) return;
if (row.IsRunning)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveRunningRejected"));
return;
}
var repo = new TaskRepository(db);
var descendantIds = await repo.GetDescendantIdsAsync(row.Id);
var ownIds = new List<string> { row.Id };
ownIds.AddRange(descendantIds);
var hasBlockingWorktree = await db.Worktrees.AsNoTracking()
.Where(w => ownIds.Contains(w.TaskId)
&& (w.State == ClaudeDo.Data.Models.WorktreeState.Active
|| w.State == ClaudeDo.Data.Models.WorktreeState.Kept))
.AnyAsync();
if (hasBlockingWorktree)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveWorktreeRejected"));
return;
}
var sourceDir = await db.Lists.AsNoTracking()
.Where(l => l.Id == sourceListId).Select(l => l.WorkingDir).FirstOrDefaultAsync();
var targetDir = targetList.WorkingDir;
var repoChanges = !string.Equals(sourceDir, targetDir, StringComparison.OrdinalIgnoreCase);
if (repoChanges)
{
if (Dialogs is null)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveConfirmUnavailable"));
return;
}
var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.moveRepoConfirm",
string.IsNullOrWhiteSpace(sourceDir) ? "—" : sourceDir,
string.IsNullOrWhiteSpace(targetDir) ? "—" : targetDir));
if (!ok) return;
}
await repo.MoveToListAsync(row.Id, targetListId);
Items.Remove(row);
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task ToggleDoneAsync(TaskRowViewModel row)
{
@@ -56,6 +56,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{
_dialogs = value;
if (Lists is not null) Lists.Dialogs = value;
if (Tasks is not null) Tasks.Dialogs = value;
}
}
@@ -116,6 +116,7 @@
IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
Classes.drop-target="{Binding IsTaskDropTarget}"
Tapped="OnItemTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnListDragOver"
@@ -31,6 +31,9 @@ public partial class TasksIslandView : UserControl
private bool _dragArmed;
private bool _dragging;
// The list row (in the Lists island) currently highlighted as a drop target while dragging.
private ListNavItemViewModel? _hintedList;
public TasksIslandView()
{
InitializeComponent();
@@ -170,6 +173,7 @@ public partial class TasksIslandView : UserControl
{
_drag.MoveTo(this.PointToScreen(e.GetPosition(this)));
UpdateReorderHint(e, topLevel);
UpdateListDropHint(e, topLevel);
}
}
@@ -205,7 +209,17 @@ public partial class TasksIslandView : UserControl
return;
}
// 2) Released over another row in the same user list → reorder.
// 2) Released over another user list in the Lists island → move the task there.
if (DataContext is TasksIslandViewModel vmMove
&& ListItemUnder(e, topLevel) is { } targetList
&& targetList.Kind == ListKind.User
&& targetList.Id != vmMove.CurrentListId)
{
await vmMove.MoveTaskToListAsync(row, targetList);
return;
}
// 3) Released over another row in the same user list → reorder.
if (DataContext is TasksIslandViewModel vm && vm.CanReorder)
{
var targetButton = RowButtonAt(e, topLevel);
@@ -218,7 +232,7 @@ public partial class TasksIslandView : UserControl
}
}
// 3) Anywhere else → cancel; EndDrag already restored the source row.
// 4) Anywhere else → cancel; EndDrag already restored the source row.
}
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
@@ -229,6 +243,7 @@ public partial class TasksIslandView : UserControl
if (!_dragArmed && !_dragging) return;
if (_pressRow is not null) _pressRow.IsDragging = false;
if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints();
ClearListDropHint();
_drag.End();
ResetPressState();
}
@@ -237,6 +252,7 @@ public partial class TasksIslandView : UserControl
{
if (_pressRow is not null) _pressRow.IsDragging = false;
if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints();
ClearListDropHint();
_drag.End();
if (_dragging) e.Pointer.Capture(null);
ResetPressState();
@@ -295,6 +311,44 @@ public partial class TasksIslandView : UserControl
return button?.DataContext is TaskRowViewModel ? button : null;
}
// The list-row Border under the cursor (Lists island), found the same way as RowButtonAt but
// walking up to a Border with the "list-item" style class (mirrors ListsIslandView's own
// FindListItemBorder) — the task drag has the pointer captured here, so a DragEventArgs never
// reaches the Lists island's own DragDrop.DragOver/Drop handlers.
private static ListNavItemViewModel? ListItemUnder(PointerEventArgs e, TopLevel topLevel)
{
var pt = e.GetPosition((Visual)topLevel);
Visual? v = topLevel.InputHitTest(pt) as Visual;
while (v is not null)
{
if (v is Border b && b.Classes.Contains("list-item"))
return b.DataContext as ListNavItemViewModel;
v = v.GetVisualParent();
}
return null;
}
// Live drop-hint while dragging over a candidate target list row.
private void UpdateListDropHint(PointerEventArgs e, TopLevel topLevel)
{
if (DataContext is not TasksIslandViewModel vm) { ClearListDropHint(); return; }
var target = ListItemUnder(e, topLevel);
if (target is null || target.Kind != ListKind.User || target.Id == vm.CurrentListId)
target = null;
if (ReferenceEquals(_hintedList, target)) return;
if (_hintedList is not null) _hintedList.IsTaskDropTarget = false;
_hintedList = target;
if (_hintedList is not null) _hintedList.IsTaskDropTarget = true;
}
private void ClearListDropHint()
{
if (_hintedList is not null) _hintedList.IsTaskDropTarget = false;
_hintedList = null;
}
// The Mission Control view model whose window contains the release point, if any.
private static MissionControlViewModel? MissionControlUnder(PixelPoint screen)
{