feat(ui): Review-Merge-Button — Claude nimmt Waiting-for-Review pro Liste ab

Neuer Button in der virtuellen Review-Liste: öffnet das Merge-Helper-Auswahlmodal
im Review-Modus (alle WaitingForReview-Tasks repo-verknüpfter Listen, gruppiert
unter Listen-Headern, alle vorgetickt) und startet pro gewählter Liste eine
ConPTY-Handler-Session direkt in der Merge-Phase — via bestehendem Handoff-Spec
(nextPhase "merge"), kein Triage/Wait davor. Handler-Host-Task wie beim vollen
"Let Claude handle it"-Lauf (HandlerBaseCommit, IsManual).
This commit is contained in:
mika kuns
2026-08-27 08:44:33 +02:00
parent d0c726baa0
commit 741c7ac58d
11 changed files with 293 additions and 8 deletions
@@ -16,6 +16,12 @@ public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
public required string StatusText { get; init; }
public string? ParentTaskId { get; init; }
/// <summary>The owning list — only set in review mode, where rows span lists.</summary>
public string ListId { get; init; } = "";
/// <summary>A non-selectable list-name section header (review mode groups rows by list).</summary>
public bool IsListHeader { get; init; }
/// <summary>True when the parent is also a candidate and rendered directly above this row.</summary>
public bool IsChild { get; init; }
@@ -37,6 +43,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private string _listId = "";
private bool _reviewMode;
// Parent id -> its child rows, built after load; drives the tick-parent-ticks-children cascade.
private Dictionary<string, List<MergeHelperTaskRowViewModel>> _childrenByParentId = new();
@@ -84,6 +91,25 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
}
/// <summary>Review mode: offers every WaitingForReview task across all repo-linked lists,
/// grouped under list-name header rows, all pre-ticked — the "merge the review pile" picker.</summary>
public void ConfigureForReview()
{
_reviewMode = true;
ScopeLabel = Loc.T("modals.mergeHelper.scopeReview");
}
/// <summary>Groups a confirmed review-mode selection by owning list, preserving row order —
/// the caller starts one merge-phase handler session per group.</summary>
public IReadOnlyList<(string ListId, IReadOnlyList<string> TaskIds)> GroupSelectionByList(IReadOnlyList<string> ids)
{
var listByTaskId = Tasks.Where(t => !t.IsListHeader).ToDictionary(t => t.Id, t => t.ListId);
return ids.Where(listByTaskId.ContainsKey)
.GroupBy(id => listByTaskId[id])
.Select(g => (g.Key, (IReadOnlyList<string>)g.ToList()))
.ToList();
}
// LoadAsync rebuilds every row from scratch and re-applies IsTickedByDefault, which would wipe
// the user's ticks out from under them on every tick — in BOTH directions. Remembering only
// the ticked ids would silently re-tick a row the user deliberately unticked (Idle/Queued/
@@ -91,7 +117,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
// already existed across the reload; only genuinely new rows get the default.
internal async Task ReconcileTickAsync()
{
if (string.IsNullOrEmpty(_listId) || _tickInFlight) return;
if ((string.IsNullOrEmpty(_listId) && !_reviewMode) || _tickInFlight) return;
_tickInFlight = true;
try
{
@@ -137,6 +163,51 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
_childrenByParentId = new();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
if (_reviewMode)
{
// Every WaitingForReview task of every repo-linked list, grouped under a header row per
// list. IsManual excludes reminders AND prior handler host tasks. Flat on purpose: a
// review parent's children are already terminal, so the hierarchy walk below has
// nothing to nest here.
var reviewCandidates = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status == TaskStatus.WaitingForReview && !t.IsManual)
.Join(ctx.Lists.Where(l => l.WorkingDir != null && l.WorkingDir != ""),
t => t.ListId, l => l.Id,
(t, l) => new { t.Id, t.Title, t.Status, t.ParentTaskId, t.ListId, ListName = l.Name, t.SortOrder, t.CreatedAt })
.OrderBy(x => x.ListName).ThenBy(x => x.SortOrder).ThenBy(x => x.CreatedAt)
.ToListAsync(ct);
foreach (var group in reviewCandidates.GroupBy(x => x.ListId))
{
Tasks.Add(new MergeHelperTaskRowViewModel
{
Id = $"header:{group.Key}",
Title = group.First().ListName,
StatusText = "",
ListId = group.Key,
IsListHeader = true,
});
foreach (var c in group)
{
var reviewRow = new MergeHelperTaskRowViewModel
{
Id = c.Id,
Title = c.Title,
StatusText = c.Status.ToString(),
ParentTaskId = c.ParentTaskId,
ListId = c.ListId,
IsSelected = true,
};
reviewRow.PropertyChanged += OnRowChanged;
Tasks.Add(reviewRow);
}
}
OnPropertyChanged(nameof(HasTasks));
OnPropertyChanged(nameof(CanConfirm));
return;
}
var candidates = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled)
.Where(t => t.ListId == _listId)
@@ -222,7 +293,8 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
[RelayCommand]
private void SelectAll()
{
foreach (var t in Tasks) t.IsSelected = true;
foreach (var t in Tasks)
if (!t.IsListHeader) t.IsSelected = true;
}
[RelayCommand]
@@ -235,7 +307,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
private void Confirm()
{
StopReconcileTick();
Result.TrySetResult(Tasks.Where(t => t.IsSelected).Select(t => t.Id).ToList());
Result.TrySetResult(Tasks.Where(t => t.IsSelected && !t.IsListHeader).Select(t => t.Id).ToList());
CloseAction?.Invoke();
}