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

@@ -239,12 +239,16 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null)
{
var result = await _wtMaintenance.CleanupFinishedAsync(listId, Context.ConnectionAborted);
foreach (var id in result.RemovedTaskIds)
await _broadcaster.WorktreeUpdated(id);
return new WorktreeCleanupDto(result.Removed);
}
public async Task<WorktreeResetDto> ResetAllWorktrees()
{
var result = await _wtMaintenance.ResetAllAsync();
foreach (var id in result.RemovedTaskIds)
await _broadcaster.WorktreeUpdated(id);
return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks);
}
@@ -262,6 +266,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var repo = new WorktreeRepository(ctx);
var existing = await repo.GetByTaskIdAsync(taskId, Context.ConnectionAborted);
if (existing is null) throw new HubException("worktree not found");
// Allowed transitions: Active -> Merged | Discarded | Kept. Terminal states are final.
if (existing.State == newState) return true;
if (existing.State != WorktreeState.Active || newState == WorktreeState.Active)
throw new HubException($"invalid worktree state transition {existing.State} -> {newState}");
await repo.SetStateAsync(taskId, newState, Context.ConnectionAborted);
await _broadcaster.WorktreeUpdated(taskId);
return true;

View File

@@ -7,8 +7,8 @@ namespace ClaudeDo.Worker.Worktrees;
public sealed class WorktreeMaintenanceService
{
public sealed record CleanupResult(int Removed);
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
public sealed record CleanupResult(int Removed, IReadOnlyList<string> RemovedTaskIds);
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks, IReadOnlyList<string> RemovedTaskIds);
public sealed record ForceRemoveResult(bool Removed, string? Reason);
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
@@ -40,12 +40,16 @@ public sealed class WorktreeMaintenanceService
var rows = await query.AsNoTracking().Select(x => x.Row).ToListAsync(ct);
int removed = 0;
var removedTaskIds = new List<string>();
foreach (var row in rows)
{
if (await TryRemoveAsync(row, force: false, ct))
{
removed++;
removedTaskIds.Add(row.TaskId);
}
}
return new CleanupResult(removed);
return new CleanupResult(removed, removedTaskIds);
}
public async Task<ResetResult> ResetAllAsync(CancellationToken ct = default)
@@ -54,7 +58,7 @@ public sealed class WorktreeMaintenanceService
var running = await context.Tasks.AsNoTracking()
.CountAsync(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running, ct);
if (running > 0)
return new ResetResult(0, 0, Blocked: true, RunningTasks: running);
return new ResetResult(0, 0, Blocked: true, RunningTasks: running, Array.Empty<string>());
var rows = await (from w in context.Worktrees
join t in context.Tasks on w.TaskId equals t.Id
@@ -64,12 +68,16 @@ public sealed class WorktreeMaintenanceService
.ToListAsync(ct);
int removed = 0;
var removedTaskIds = new List<string>();
foreach (var row in rows)
{
if (await TryRemoveAsync(row, force: true, ct))
{
removed++;
removedTaskIds.Add(row.TaskId);
}
}
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0);
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0, removedTaskIds);
}
public async Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(
@@ -122,9 +130,11 @@ public sealed class WorktreeMaintenanceService
private async Task<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
{
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
bool dirRemoved;
if (repoDirExists)
{
dirRemoved = true;
try
{
await _git.WorktreeRemoveAsync(row.WorkingDir!, row.Path, force, ct);
@@ -137,15 +147,19 @@ public sealed class WorktreeMaintenanceService
catch (Exception delEx)
{
_logger.LogError(delEx, "Directory.Delete fallback also failed for {Path}", row.Path);
dirRemoved = false;
}
}
if (Directory.Exists(row.Path)) dirRemoved = false;
}
else
{
dirRemoved = true;
try { if (Directory.Exists(row.Path)) Directory.Delete(row.Path, recursive: true); }
catch (Exception ex)
{
_logger.LogError(ex, "Directory.Delete failed for {Path}", row.Path);
dirRemoved = false;
}
}
@@ -170,6 +184,10 @@ public sealed class WorktreeMaintenanceService
}
}
// Drop the DB row only when the on-disk worktree is gone; otherwise we'd silently
// strand a directory while reporting success.
if (!dirRemoved) return false;
using var context = _dbFactory.CreateDbContext();
await context.Worktrees.Where(w => w.TaskId == row.TaskId).ExecuteDeleteAsync(ct);
return true;