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).
331 lines
14 KiB
C#
331 lines
14 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.ComponentModel;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Ui.Localization;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
|
|
|
public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
|
|
{
|
|
public required string Id { get; init; }
|
|
public required string Title { get; init; }
|
|
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; }
|
|
|
|
/// <summary>
|
|
/// True when <see cref="ParentTaskId"/> is set but the parent isn't a candidate itself
|
|
/// (e.g. already Done/Cancelled) — the row still renders top-level, flagged with a hint.
|
|
/// </summary>
|
|
public bool IsOrphanChild { get; init; }
|
|
|
|
[ObservableProperty] private bool _isSelected;
|
|
}
|
|
|
|
/// <summary>
|
|
/// "Let Claude handle it" task picker: lists the non-terminal tasks of a list (or all lists),
|
|
/// pre-ticks the actionable ones, and resolves <see cref="Result"/> with the ordered selected
|
|
/// task ids (null on cancel).
|
|
/// </summary>
|
|
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();
|
|
|
|
// Set while ReconcileTickAsync restores remembered ticks, so replaying a parent's state
|
|
// doesn't cascade over its children's own remembered state.
|
|
private bool _suppressCascade;
|
|
|
|
// Only one tick may be in flight. A second one entering while the first awaits LoadAsync
|
|
// would snapshot an already-cleared Tasks collection, restore that emptiness, and let
|
|
// IsTickedByDefault re-tick every row — silently undoing the user's unticks, i.e. exactly
|
|
// what the remember/restore below exists to prevent.
|
|
private bool _tickInFlight;
|
|
|
|
// Test seam: awaited inside ReconcileTickAsync after the reload and before the restore, so a
|
|
// test can hold one tick open in exactly the window a second one would corrupt. Null in prod.
|
|
internal Func<Task>? ReconcileTickTestBarrier { get; set; }
|
|
|
|
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
|
|
|
|
[ObservableProperty] private string _scopeLabel = "";
|
|
|
|
public bool HasTasks => Tasks.Count > 0;
|
|
public bool CanConfirm => Tasks.Any(t => t.IsSelected);
|
|
|
|
public TaskCompletionSource<IReadOnlyList<string>?> Result { get; } = new();
|
|
public Action? CloseAction { get; set; }
|
|
|
|
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
|
|
|
|
public MergeHelperSelectionModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
// Phase 3 reconcile tick: this overlay is long-lived (stays open while the user ticks
|
|
// through candidates), so refresh it on the same cadence as TasksIslandViewModel's tick
|
|
// instead of leaving it frozen at the moment it was opened.
|
|
_reconcileTimer.Elapsed += (_, _) =>
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync());
|
|
_reconcileTimer.Start();
|
|
}
|
|
|
|
public void Configure(string listId, string listName)
|
|
{
|
|
_listId = listId;
|
|
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/
|
|
// WaitingForReview/Failed all default to ticked), so carry the full state of every row that
|
|
// already existed across the reload; only genuinely new rows get the default.
|
|
internal async Task ReconcileTickAsync()
|
|
{
|
|
if ((string.IsNullOrEmpty(_listId) && !_reviewMode) || _tickInFlight) return;
|
|
_tickInFlight = true;
|
|
try
|
|
{
|
|
// Hold the row INSTANCES, not a value snapshot, and read their state back only after
|
|
// the reload: a tick the user lands during the await happens before LoadAsync clears
|
|
// the collection, so the live instance still carries it. Snapshotting up front would
|
|
// drop that click.
|
|
var previousRows = Tasks.ToList();
|
|
await LoadAsync();
|
|
|
|
if (ReconcileTickTestBarrier is { } barrier) await barrier();
|
|
|
|
var previous = new Dictionary<string, bool>(previousRows.Count);
|
|
foreach (var t in previousRows) previous[t.Id] = t.IsSelected;
|
|
|
|
// Restore without the parent→children cascade: it would overwrite a child's own
|
|
// remembered state with its parent's, undoing exactly the per-child choice being
|
|
// restored.
|
|
_suppressCascade = true;
|
|
try
|
|
{
|
|
foreach (var t in Tasks)
|
|
if (previous.TryGetValue(t.Id, out var wasSelected))
|
|
t.IsSelected = wasSelected;
|
|
}
|
|
finally
|
|
{
|
|
_suppressCascade = false;
|
|
}
|
|
|
|
OnPropertyChanged(nameof(CanConfirm));
|
|
}
|
|
finally
|
|
{
|
|
_tickInFlight = false;
|
|
}
|
|
}
|
|
|
|
public async Task LoadAsync(CancellationToken ct = default)
|
|
{
|
|
foreach (var row in Tasks) row.PropertyChanged -= OnRowChanged;
|
|
Tasks.Clear();
|
|
_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)
|
|
// Manual tasks are reminders the user owns — never offer them to the handler.
|
|
.Where(t => !t.IsManual)
|
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
|
.Select(t => new { t.Id, t.Title, t.Status, t.ParentTaskId })
|
|
.ToListAsync(ct);
|
|
|
|
var candidateIds = candidates.Select(c => c.Id).ToHashSet();
|
|
bool ParentIsCandidate(string? parentId) =>
|
|
!string.IsNullOrEmpty(parentId) && candidateIds.Contains(parentId);
|
|
|
|
var childrenByParent = candidates
|
|
.Where(c => ParentIsCandidate(c.ParentTaskId))
|
|
.GroupBy(c => c.ParentTaskId!)
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
// Hierarchy-ordered walk, same idea as TasksIslandViewModel.Regroup: top-level rows
|
|
// (including orphaned children whose parent isn't a candidate) interleaved with their
|
|
// visible children, preserving the SortOrder/CreatedAt order within each level.
|
|
var emitted = new HashSet<string>();
|
|
var ordered = new List<(string Id, string Title, TaskStatus Status, string? ParentTaskId)>();
|
|
void Emit(string id, string title, TaskStatus status, string? parentTaskId)
|
|
{
|
|
if (!emitted.Add(id)) return;
|
|
ordered.Add((id, title, status, parentTaskId));
|
|
if (childrenByParent.TryGetValue(id, out var kids))
|
|
foreach (var kid in kids)
|
|
Emit(kid.Id, kid.Title, kid.Status, kid.ParentTaskId);
|
|
}
|
|
foreach (var c in candidates.Where(c => !ParentIsCandidate(c.ParentTaskId)))
|
|
Emit(c.Id, c.Title, c.Status, c.ParentTaskId);
|
|
|
|
foreach (var c in ordered)
|
|
{
|
|
var isChild = ParentIsCandidate(c.ParentTaskId);
|
|
var row = new MergeHelperTaskRowViewModel
|
|
{
|
|
Id = c.Id,
|
|
Title = c.Title,
|
|
StatusText = c.Status.ToString(),
|
|
ParentTaskId = c.ParentTaskId,
|
|
IsChild = isChild,
|
|
IsOrphanChild = !isChild && !string.IsNullOrEmpty(c.ParentTaskId),
|
|
IsSelected = IsTickedByDefault(c.Status),
|
|
};
|
|
row.PropertyChanged += OnRowChanged;
|
|
Tasks.Add(row);
|
|
}
|
|
|
|
_childrenByParentId = Tasks
|
|
.Where(r => r.IsChild)
|
|
.GroupBy(r => r.ParentTaskId!)
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
OnPropertyChanged(nameof(HasTasks));
|
|
OnPropertyChanged(nameof(CanConfirm));
|
|
}
|
|
|
|
// Actionable statuses are pre-ticked; Running/WaitingForChildren are listed but unticked
|
|
// (the helper would only poll them). Done/Cancelled never make it into the list.
|
|
internal static bool IsTickedByDefault(TaskStatus status) => status
|
|
is TaskStatus.Idle or TaskStatus.Queued or TaskStatus.WaitingForReview or TaskStatus.Failed;
|
|
|
|
private void OnRowChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName != nameof(MergeHelperTaskRowViewModel.IsSelected)) return;
|
|
|
|
// Ticking/unticking a parent cascades to its children; a child can still be
|
|
// deselected on its own afterwards without affecting the parent's checkbox —
|
|
// ClaudeDo has no indeterminate/tri-state parent checkbox.
|
|
if (!_suppressCascade
|
|
&& sender is MergeHelperTaskRowViewModel row
|
|
&& _childrenByParentId.TryGetValue(row.Id, out var children))
|
|
{
|
|
foreach (var child in children) child.IsSelected = row.IsSelected;
|
|
}
|
|
|
|
OnPropertyChanged(nameof(CanConfirm));
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void SelectAll()
|
|
{
|
|
foreach (var t in Tasks)
|
|
if (!t.IsListHeader) t.IsSelected = true;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void SelectNone()
|
|
{
|
|
foreach (var t in Tasks) t.IsSelected = false;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void Confirm()
|
|
{
|
|
StopReconcileTick();
|
|
Result.TrySetResult(Tasks.Where(t => t.IsSelected && !t.IsListHeader).Select(t => t.Id).ToList());
|
|
CloseAction?.Invoke();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void Cancel()
|
|
{
|
|
StopReconcileTick();
|
|
Result.TrySetResult(null);
|
|
CloseAction?.Invoke();
|
|
}
|
|
|
|
// Also called from the dialog's native-close fallback (WindowDialogService), which resolves
|
|
// Result without going through Confirm/Cancel. Idempotent — Timer.Stop()/Dispose() tolerate
|
|
// repeated calls.
|
|
internal void StopReconcileTick()
|
|
{
|
|
_reconcileTimer.Stop();
|
|
_reconcileTimer.Dispose();
|
|
}
|
|
}
|