feat(ui): merge action and robust jump-to-task in worktrees overview

Add Merge entry to the worktrees overview context menu wiring the existing
MergeModalViewModel, replace fire-and-forget list selection with a
collection-change-aware JumpToTaskHelper, and propagate list renames to
visible task rows via a new ListUpdated event.

Harden worktree state changes: WorkerHub.SetWorktreeState now rejects
invalid transitions, WorktreeMaintenanceService only drops the DB row when
the on-disk worktree was actually removed, and Cleanup/Reset broadcast
WorktreeUpdated for affected tasks. SetWorktreeStateAsync returns the hub
error message so the modal can surface it.

Also: de-duplicate the worktrees overview modal opener, hook
OnParentTaskIdChanged to refresh IsDraft, fix MergeModal CanExecute
notifications, and add WorktreeStateHubTests for the transition rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-05-27 13:43:39 +02:00
parent 2223839595
commit 967e0cd319
18 changed files with 416 additions and 53 deletions

View File

@@ -24,6 +24,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
[NotifyCanExecuteChangedFor(nameof(ReviewCombinedDiffCommand))]
[NotifyPropertyChangedFor(nameof(TaskIdBadge))]
private TaskRowViewModel? _task;
// Editable fields

View File

@@ -50,16 +50,24 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
await RefreshRowAsync(row.Id);
}
private bool _worktreesOverviewOpen;
[RelayCommand]
private async Task OpenWorktreesOverviewAsync(ListNavItemViewModel? row)
{
if (row is null || ShowWorktreesOverviewModal is null || _services is null) return;
if (row.Kind != ListKind.User) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
vm.Configure(rawId, row.Name);
await vm.LoadAsync();
await ShowWorktreesOverviewModal(vm);
if (_worktreesOverviewOpen) return;
_worktreesOverviewOpen = true;
try
{
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
vm.Configure(rawId, row.Name);
await vm.LoadAsync();
await ShowWorktreesOverviewModal(vm);
}
finally { _worktreesOverviewOpen = false; }
}
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
@@ -91,6 +99,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
_worker.WorktreeUpdatedEvent += _id => _ = RefreshCountsAsync();
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
}
}

View File

@@ -92,6 +92,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanSendToQueue));
}
partial void OnParentTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsChild));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(CanOpenPlanningSession));
}
partial void OnPlanningPhaseChanged(PlanningPhase value)
{
OnPropertyChanged(nameof(IsPlanningParent));
@@ -113,12 +120,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(StatusChipClass));
}
partial void OnParentTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsChild));
OnPropertyChanged(nameof(CanOpenPlanningSession));
}
partial void OnHasPlanningChildrenChanged(bool value)
=> OnPropertyChanged(nameof(IsPlanningParent));

View File

@@ -57,6 +57,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
_worker.TaskMessageEvent += OnWorkerTaskMessage;
_worker.ListUpdatedEvent += OnWorkerListUpdated;
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
}
}
@@ -67,6 +68,29 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
if (row is not null) row.LiveTail = line;
}
private async void OnWorkerListUpdated(string listId)
{
// Mirror the renamed list onto every task row that references it,
// so the per-row ListName chip on virtual lists stays current.
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
if (entity is null) return;
var visibleIds = Items.Select(r => r.Id).ToHashSet();
if (visibleIds.Count == 0) return;
var matchingIds = await db.Tasks.AsNoTracking()
.Where(t => t.ListId == listId && visibleIds.Contains(t.Id))
.Select(t => t.Id)
.ToListAsync();
var matching = matchingIds.ToHashSet();
foreach (var row in Items)
if (matching.Contains(row.Id) && row.ListName != entity.Name)
row.ListName = entity.Name;
}
catch { }
}
private async void OnWorkerTaskUpdated(string taskId)
{
var list = _currentList;