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:
@@ -154,6 +154,7 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"showCompletedTip": "Abgeschlossene anzeigen",
|
"showCompletedTip": "Abgeschlossene anzeigen",
|
||||||
"letClaudeTip": "Claude machen lassen",
|
"letClaudeTip": "Claude machen lassen",
|
||||||
|
"mergeReviewsTip": "Claude die Reviews abnehmen lassen",
|
||||||
"quickClaudeTip": "Schnelle Claude-Sitzung",
|
"quickClaudeTip": "Schnelle Claude-Sitzung",
|
||||||
"listSettingsTip": "Listeneinstellungen",
|
"listSettingsTip": "Listeneinstellungen",
|
||||||
"addPlaceholder": "Aufgabe hinzufügen…",
|
"addPlaceholder": "Aufgabe hinzufügen…",
|
||||||
@@ -311,6 +312,8 @@
|
|||||||
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
|
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
|
||||||
"mergeHelperTaskTitle": "Listen-Handler: {0}",
|
"mergeHelperTaskTitle": "Listen-Handler: {0}",
|
||||||
"mergeHelperTaskDescriptionHeader": "Von diesem Lauf bearbeitete Tasks:",
|
"mergeHelperTaskDescriptionHeader": "Von diesem Lauf bearbeitete Tasks:",
|
||||||
|
"mergeReviewsTitle": "Review-Merge",
|
||||||
|
"mergeReviewsTaskTitle": "Review-Merge: {0}",
|
||||||
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
|
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
|
||||||
"submitForReview": "Zum Review einreichen",
|
"submitForReview": "Zum Review einreichen",
|
||||||
"submitForReviewTip": "Diesen Worktree committen und den Task ins Review bringen, damit er gemergt werden kann",
|
"submitForReviewTip": "Diesen Worktree committen und den Task ins Review bringen, damit er gemergt werden kann",
|
||||||
@@ -438,6 +441,7 @@
|
|||||||
"title": "CLAUDE MACHEN LASSEN",
|
"title": "CLAUDE MACHEN LASSEN",
|
||||||
"windowTitle": "Claude machen lassen",
|
"windowTitle": "Claude machen lassen",
|
||||||
"scopeList": "Liste: {0}",
|
"scopeList": "Liste: {0}",
|
||||||
|
"scopeReview": "Alle Listen — Tasks im Review",
|
||||||
"confirm": "Claude machen lassen",
|
"confirm": "Claude machen lassen",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"selectAll": "Alle auswählen",
|
"selectAll": "Alle auswählen",
|
||||||
|
|||||||
@@ -154,6 +154,7 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"showCompletedTip": "Show completed",
|
"showCompletedTip": "Show completed",
|
||||||
"letClaudeTip": "Let Claude handle it",
|
"letClaudeTip": "Let Claude handle it",
|
||||||
|
"mergeReviewsTip": "Let Claude merge the reviews",
|
||||||
"quickClaudeTip": "Quick Claude session",
|
"quickClaudeTip": "Quick Claude session",
|
||||||
"listSettingsTip": "List settings",
|
"listSettingsTip": "List settings",
|
||||||
"addPlaceholder": "Add a task…",
|
"addPlaceholder": "Add a task…",
|
||||||
@@ -311,6 +312,8 @@
|
|||||||
"mergeHelperHandoffTitleSuffix": " (Handoff)",
|
"mergeHelperHandoffTitleSuffix": " (Handoff)",
|
||||||
"mergeHelperTaskTitle": "List handler: {0}",
|
"mergeHelperTaskTitle": "List handler: {0}",
|
||||||
"mergeHelperTaskDescriptionHeader": "Tasks handled by this run:",
|
"mergeHelperTaskDescriptionHeader": "Tasks handled by this run:",
|
||||||
|
"mergeReviewsTitle": "Review Merge",
|
||||||
|
"mergeReviewsTaskTitle": "Review merge: {0}",
|
||||||
"submitForReviewFailed": "Couldn't submit for review: {0}",
|
"submitForReviewFailed": "Couldn't submit for review: {0}",
|
||||||
"submitForReview": "Submit for review",
|
"submitForReview": "Submit for review",
|
||||||
"submitForReviewTip": "Commit this worktree and move the task to review so it can be merged",
|
"submitForReviewTip": "Commit this worktree and move the task to review so it can be merged",
|
||||||
@@ -438,6 +441,7 @@
|
|||||||
"title": "LET CLAUDE HANDLE IT",
|
"title": "LET CLAUDE HANDLE IT",
|
||||||
"windowTitle": "Let Claude handle it",
|
"windowTitle": "Let Claude handle it",
|
||||||
"scopeList": "List: {0}",
|
"scopeList": "List: {0}",
|
||||||
|
"scopeReview": "All lists — tasks waiting for review",
|
||||||
"confirm": "Let Claude handle it",
|
"confirm": "Let Claude handle it",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"selectAll": "Select all",
|
"selectAll": "Select all",
|
||||||
|
|||||||
@@ -105,6 +105,25 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids));
|
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Raised once per list after the review-scoped selection dialog is confirmed;
|
||||||
|
/// the shell opens one merge-phase ConPTY tile per request.</summary>
|
||||||
|
public event Action<MergeHelperRequest>? MergeReviewsRequested;
|
||||||
|
|
||||||
|
// Review-list variant of LetClaudeHandleListAsync: offers every WaitingForReview task across
|
||||||
|
// all repo-linked lists and starts one merge-phase handler session per list picked.
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task MergeReviewsAsync()
|
||||||
|
{
|
||||||
|
if (Dialogs is null || _services is null) return;
|
||||||
|
var vm = _services.GetRequiredService<MergeHelperSelectionModalViewModel>();
|
||||||
|
vm.ConfigureForReview();
|
||||||
|
await vm.LoadAsync();
|
||||||
|
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
|
||||||
|
if (ids is not { Count: > 0 }) return;
|
||||||
|
foreach (var (listId, taskIds) in vm.GroupSelectionByList(ids))
|
||||||
|
MergeReviewsRequested?.Invoke(new MergeHelperRequest(listId, taskIds));
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenInExplorer(ListNavItemViewModel? row)
|
private void OpenInExplorer(ListNavItemViewModel? row)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
|
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
|
||||||
private bool _isLetClaudeVisible;
|
private bool _isLetClaudeVisible;
|
||||||
[ObservableProperty] private bool _isQuickClaudeVisible;
|
[ObservableProperty] private bool _isQuickClaudeVisible;
|
||||||
|
[ObservableProperty] private bool _isMergeReviewsVisible;
|
||||||
|
|
||||||
/// <summary>No visible tasks below the add-task row — every item lands in one of
|
/// <summary>No visible tasks below the add-task row — every item lands in one of
|
||||||
/// Overdue/Open/Completed, so all-false here always means the list has zero items.</summary>
|
/// Overdue/Open/Completed, so all-false here always means the list has zero items.</summary>
|
||||||
@@ -147,6 +148,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void LetClaudeHandle() => LetClaudeHandleRequested?.Invoke(this, EventArgs.Empty);
|
private void LetClaudeHandle() => LetClaudeHandleRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
// "Let Claude merge the reviews" on the virtual review list — the shell routes it to the
|
||||||
|
// review-scoped selection modal, which starts one merge-phase handler session per list.
|
||||||
|
public event EventHandler? MergeReviewsRequested;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void MergeReviews() => MergeReviewsRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
// Opens a task-less ConPTY session directly in the current list's working dir. The shell owns
|
// Opens a task-less ConPTY session directly in the current list's working dir. The shell owns
|
||||||
// Mission Control, so this just raises an event for it to act on (mirrors OpenConPtySessionRequested).
|
// Mission Control, so this just raises an event for it to act on (mirrors OpenConPtySessionRequested).
|
||||||
public event Action<string>? OpenQuickClaudeSessionRequested;
|
public event Action<string>? OpenQuickClaudeSessionRequested;
|
||||||
@@ -411,7 +419,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
ShowOpenLabel = false;
|
ShowOpenLabel = false;
|
||||||
ShowNotesRow = false;
|
ShowNotesRow = false;
|
||||||
if (listChanged) SelectFrom(null, "list-change");
|
if (listChanged) SelectFrom(null, "list-change");
|
||||||
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
|
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; IsMergeReviewsVisible = false; LoadTask = Task.CompletedTask; return; }
|
||||||
|
|
||||||
HeaderTitle = list.Name;
|
HeaderTitle = list.Name;
|
||||||
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
|
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
|
||||||
@@ -419,6 +427,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
IsMyDayList = list.Id == "smart:my-day";
|
IsMyDayList = list.Id == "smart:my-day";
|
||||||
IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
|
IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
|
||||||
IsQuickClaudeVisible = IsLetClaudeVisible;
|
IsQuickClaudeVisible = IsLetClaudeVisible;
|
||||||
|
IsMergeReviewsVisible = list.Id == "virtual:review";
|
||||||
|
|
||||||
LoadTask = LoadForListAsync(list, ct, reusable);
|
LoadTask = LoadForListAsync(list, ct, reusable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,6 +357,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
OpenMissionControl();
|
OpenMissionControl();
|
||||||
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds);
|
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds);
|
||||||
};
|
};
|
||||||
|
Lists.MergeReviewsRequested += req =>
|
||||||
|
{
|
||||||
|
OpenMissionControl();
|
||||||
|
_ = MissionControl.OpenMergeReviewConPtySessionAsync(req.ListId, req.TaskIds);
|
||||||
|
};
|
||||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||||
Tasks.OpenListSettingsRequested += (_, _) =>
|
Tasks.OpenListSettingsRequested += (_, _) =>
|
||||||
{
|
{
|
||||||
@@ -368,6 +373,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
if (Lists.SelectedList is { } row)
|
if (Lists.SelectedList is { } row)
|
||||||
Lists.LetClaudeHandleListCommand.Execute(row);
|
Lists.LetClaudeHandleListCommand.Execute(row);
|
||||||
};
|
};
|
||||||
|
Tasks.MergeReviewsRequested += (_, _) => Lists.MergeReviewsCommand.Execute(null);
|
||||||
Details.ErrorReported += FlashFooterError;
|
Details.ErrorReported += FlashFooterError;
|
||||||
Details.CloseDetail = () => Tasks.SelectFrom(null, "close-detail");
|
Details.CloseDetail = () => Tasks.SelectFrom(null, "close-detail");
|
||||||
Details.DeleteFromList = row =>
|
Details.DeleteFromList = row =>
|
||||||
|
|||||||
@@ -239,7 +239,22 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
|
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
|
||||||
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
|
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
|
||||||
// (no worktree of its own) — see TaskEntity.HandlerBaseCommit/HandlerHeadCommit.
|
// (no worktree of its own) — see TaskEntity.HandlerBaseCommit/HandlerHeadCommit.
|
||||||
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
|
public System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
|
||||||
|
=> OpenListHandlerConPtySessionAsync(listId, taskIds,
|
||||||
|
"missionControl.mergeHelperTitle", "missionControl.mergeHelperTaskTitle",
|
||||||
|
taskId => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId));
|
||||||
|
|
||||||
|
// Merge-only list-handler session over a list's WaitingForReview tasks (the review list's
|
||||||
|
// "Let Claude merge the reviews"). Same host-task shape as the full run, but the session
|
||||||
|
// starts directly at the merge phase via the existing handoff spec — no triage/wait first.
|
||||||
|
public System.Threading.Tasks.Task OpenMergeReviewConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
|
||||||
|
=> OpenListHandlerConPtySessionAsync(listId, taskIds,
|
||||||
|
"missionControl.mergeReviewsTitle", "missionControl.mergeReviewsTaskTitle",
|
||||||
|
taskId => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, taskIds, "merge"));
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task OpenListHandlerConPtySessionAsync(
|
||||||
|
string listId, IReadOnlyList<string> taskIds, string titleKey, string taskTitleKey,
|
||||||
|
Func<string, System.Threading.Tasks.Task<LaunchSpec>> launchSpec)
|
||||||
{
|
{
|
||||||
if (taskIds is not { Count: > 0 }) return;
|
if (taskIds is not { Count: > 0 }) return;
|
||||||
// The TaskId dedupe below can't help here — CreateMergeHelperTaskAsync mints a brand-new
|
// The TaskId dedupe below can't help here — CreateMergeHelperTaskAsync mints a brand-new
|
||||||
@@ -249,7 +264,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var title = Loc.T("missionControl.mergeHelperTitle");
|
var title = Loc.T(titleKey);
|
||||||
var listName = listId;
|
var listName = listId;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -263,7 +278,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
taskId = await _worker.CreateMergeHelperTaskAsync(taskIds, listId,
|
taskId = await _worker.CreateMergeHelperTaskAsync(taskIds, listId,
|
||||||
Loc.T("missionControl.mergeHelperTaskTitle", listName),
|
Loc.T(taskTitleKey, listName),
|
||||||
Loc.T("missionControl.mergeHelperTaskDescriptionHeader"));
|
Loc.T("missionControl.mergeHelperTaskDescriptionHeader"));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -279,7 +294,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||||
() => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId))));
|
() => DescribeAsync(() => launchSpec(taskId))));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
|
|||||||
public required string StatusText { get; init; }
|
public required string StatusText { get; init; }
|
||||||
public string? ParentTaskId { 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>
|
/// <summary>True when the parent is also a candidate and rendered directly above this row.</summary>
|
||||||
public bool IsChild { get; init; }
|
public bool IsChild { get; init; }
|
||||||
|
|
||||||
@@ -37,6 +43,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private string _listId = "";
|
private string _listId = "";
|
||||||
|
private bool _reviewMode;
|
||||||
|
|
||||||
// Parent id -> its child rows, built after load; drives the tick-parent-ticks-children cascade.
|
// Parent id -> its child rows, built after load; drives the tick-parent-ticks-children cascade.
|
||||||
private Dictionary<string, List<MergeHelperTaskRowViewModel>> _childrenByParentId = new();
|
private Dictionary<string, List<MergeHelperTaskRowViewModel>> _childrenByParentId = new();
|
||||||
@@ -84,6 +91,25 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
|
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
|
// 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 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/
|
// 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.
|
// already existed across the reload; only genuinely new rows get the default.
|
||||||
internal async Task ReconcileTickAsync()
|
internal async Task ReconcileTickAsync()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_listId) || _tickInFlight) return;
|
if ((string.IsNullOrEmpty(_listId) && !_reviewMode) || _tickInFlight) return;
|
||||||
_tickInFlight = true;
|
_tickInFlight = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -137,6 +163,51 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
_childrenByParentId = new();
|
_childrenByParentId = new();
|
||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
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()
|
var candidates = await ctx.Tasks.AsNoTracking()
|
||||||
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled)
|
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled)
|
||||||
.Where(t => t.ListId == _listId)
|
.Where(t => t.ListId == _listId)
|
||||||
@@ -222,7 +293,8 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SelectAll()
|
private void SelectAll()
|
||||||
{
|
{
|
||||||
foreach (var t in Tasks) t.IsSelected = true;
|
foreach (var t in Tasks)
|
||||||
|
if (!t.IsListHeader) t.IsSelected = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -235,7 +307,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
private void Confirm()
|
private void Confirm()
|
||||||
{
|
{
|
||||||
StopReconcileTick();
|
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();
|
CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@
|
|||||||
Command="{Binding LetClaudeHandleCommand}" ToolTip.Tip="{loc:Tr tasks.letClaudeTip}">
|
Command="{Binding LetClaudeHandleCommand}" ToolTip.Tip="{loc:Tr tasks.letClaudeTip}">
|
||||||
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.AgentSuggested}"/>
|
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.AgentSuggested}"/>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button Classes="icon-btn" IsVisible="{Binding IsMergeReviewsVisible}"
|
||||||
|
Command="{Binding MergeReviewsCommand}" ToolTip.Tip="{loc:Tr tasks.mergeReviewsTip}">
|
||||||
|
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.GitBranch}"/>
|
||||||
|
</Button>
|
||||||
<Button Classes="icon-btn" IsVisible="{Binding IsQuickClaudeVisible}"
|
<Button Classes="icon-btn" IsVisible="{Binding IsQuickClaudeVisible}"
|
||||||
Command="{Binding OpenQuickClaudeSessionCommand}" ToolTip.Tip="{loc:Tr tasks.quickClaudeTip}">
|
Command="{Binding OpenQuickClaudeSessionCommand}" ToolTip.Tip="{loc:Tr tasks.quickClaudeTip}">
|
||||||
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.ArrowOut}"/>
|
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.ArrowOut}"/>
|
||||||
|
|||||||
@@ -53,6 +53,10 @@
|
|||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate DataType="vm:MergeHelperTaskRowViewModel">
|
<DataTemplate DataType="vm:MergeHelperTaskRowViewModel">
|
||||||
<Grid ColumnDefinitions="24,32,*,120" Margin="0,1">
|
<Grid ColumnDefinitions="24,32,*,120" Margin="0,1">
|
||||||
|
<!-- Review mode: non-selectable list-name section header -->
|
||||||
|
<TextBlock Grid.Column="0" Grid.ColumnSpan="3" Classes="eyebrow"
|
||||||
|
Text="{Binding Title}" IsVisible="{Binding IsListHeader}"
|
||||||
|
Margin="0,10,0,2" VerticalAlignment="Center"/>
|
||||||
<!-- Indent track for children, matching the task list's parent/child rendering -->
|
<!-- Indent track for children, matching the task list's parent/child rendering -->
|
||||||
<Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
|
<Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
|
||||||
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
|
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
|
||||||
@@ -60,8 +64,10 @@
|
|||||||
</Border>
|
</Border>
|
||||||
<CheckBox Grid.Column="1" MinWidth="0"
|
<CheckBox Grid.Column="1" MinWidth="0"
|
||||||
IsChecked="{Binding IsSelected, Mode=TwoWay}"
|
IsChecked="{Binding IsSelected, Mode=TwoWay}"
|
||||||
|
IsVisible="{Binding !IsListHeader}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4"
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4"
|
||||||
|
IsVisible="{Binding !IsListHeader}"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<TextBlock Classes="body" Text="{Binding Title}"
|
<TextBlock Classes="body" Text="{Binding Title}"
|
||||||
VerticalAlignment="Center" Margin="4,0,0,0"
|
VerticalAlignment="Center" Margin="4,0,0,0"
|
||||||
@@ -73,6 +79,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Border Grid.Column="3" Classes="chip"
|
<Border Grid.Column="3" Classes="chip"
|
||||||
|
IsVisible="{Binding !IsListHeader}"
|
||||||
HorizontalAlignment="Left" VerticalAlignment="Center">
|
HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||||
<TextBlock Text="{Binding StatusText}"/>
|
<TextBlock Text="{Binding StatusText}"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -383,6 +383,115 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
|
|||||||
Assert.False(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected);
|
Assert.False(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Review mode (ConfigureForReview): all lists, WaitingForReview only ----
|
||||||
|
|
||||||
|
private async Task SeedReviewAcrossListsAsync()
|
||||||
|
{
|
||||||
|
await using var db = NewContext();
|
||||||
|
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", WorkingDir = "C:\\repo1", CreatedAt = DateTime.UtcNow });
|
||||||
|
db.Lists.Add(new ListEntity { Id = "L2", Name = "Home", WorkingDir = "C:\\repo2", CreatedAt = DateTime.UtcNow });
|
||||||
|
db.Lists.Add(new ListEntity { Id = "L3", Name = "NoRepo", CreatedAt = DateTime.UtcNow });
|
||||||
|
var order = 0;
|
||||||
|
void Add(string id, string listId, TaskStatus status, bool isManual = false) =>
|
||||||
|
db.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Number = TestTaskNumbers.Next(),
|
||||||
|
Id = id, ListId = listId, Title = $"task {id}", IsManual = isManual,
|
||||||
|
Status = status, CreatedAt = DateTime.UtcNow, SortOrder = order++,
|
||||||
|
});
|
||||||
|
|
||||||
|
Add("r1", "L1", TaskStatus.WaitingForReview);
|
||||||
|
Add("r2", "L1", TaskStatus.WaitingForReview);
|
||||||
|
Add("i1", "L1", TaskStatus.Idle); // wrong status
|
||||||
|
Add("m1", "L1", TaskStatus.WaitingForReview, isManual: true); // handler/reminder task
|
||||||
|
Add("r3", "L2", TaskStatus.WaitingForReview);
|
||||||
|
Add("r4", "L3", TaskStatus.WaitingForReview); // list without working dir
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReviewMode_LoadsOnlyReviewTasksOfRepoLists_GroupedWithHeaders_AllTicked()
|
||||||
|
{
|
||||||
|
await SeedReviewAcrossListsAsync();
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.ConfigureForReview();
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
// Ordered by list name: Home (L2) before Work (L1); L3 (no repo), i1, m1 excluded.
|
||||||
|
Assert.Equal(new[] { "header:L2", "r3", "header:L1", "r1", "r2" }, vm.Tasks.Select(t => t.Id));
|
||||||
|
Assert.True(vm.Tasks[0].IsListHeader);
|
||||||
|
Assert.Equal("Home", vm.Tasks[0].Title);
|
||||||
|
Assert.True(vm.Tasks[2].IsListHeader);
|
||||||
|
Assert.Equal("Work", vm.Tasks[2].Title);
|
||||||
|
Assert.All(vm.Tasks.Where(t => !t.IsListHeader), t => Assert.True(t.IsSelected));
|
||||||
|
Assert.All(vm.Tasks.Where(t => t.IsListHeader), t => Assert.False(t.IsSelected));
|
||||||
|
Assert.True(vm.CanConfirm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReviewMode_SelectAll_DoesNotTickHeaders()
|
||||||
|
{
|
||||||
|
await SeedReviewAcrossListsAsync();
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.ConfigureForReview();
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
vm.SelectNoneCommand.Execute(null);
|
||||||
|
vm.SelectAllCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.All(vm.Tasks.Where(t => t.IsListHeader), t => Assert.False(t.IsSelected));
|
||||||
|
Assert.All(vm.Tasks.Where(t => !t.IsListHeader), t => Assert.True(t.IsSelected));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReviewMode_ConfirmAndGroupSelectionByList_GroupsInRowOrder()
|
||||||
|
{
|
||||||
|
await SeedReviewAcrossListsAsync();
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.ConfigureForReview();
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
vm.ConfirmCommand.Execute(null);
|
||||||
|
var ids = await vm.Result.Task;
|
||||||
|
Assert.NotNull(ids);
|
||||||
|
Assert.Equal(new[] { "r3", "r1", "r2" }, ids);
|
||||||
|
|
||||||
|
var groups = vm.GroupSelectionByList(ids!);
|
||||||
|
Assert.Equal(2, groups.Count);
|
||||||
|
Assert.Equal("L2", groups[0].ListId);
|
||||||
|
Assert.Equal(new[] { "r3" }, groups[0].TaskIds);
|
||||||
|
Assert.Equal("L1", groups[1].ListId);
|
||||||
|
Assert.Equal(new[] { "r1", "r2" }, groups[1].TaskIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReviewMode_ReconcileTick_KeepsUnticks_AndPicksUpNewReviewTasks()
|
||||||
|
{
|
||||||
|
await SeedReviewAcrossListsAsync();
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.ConfigureForReview();
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
vm.Tasks.Single(t => t.Id == "r1").IsSelected = false;
|
||||||
|
|
||||||
|
await using (var db = NewContext())
|
||||||
|
{
|
||||||
|
db.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Number = TestTaskNumbers.Next(),
|
||||||
|
Id = "r5", ListId = "L1", Title = "arrived later",
|
||||||
|
Status = TaskStatus.WaitingForReview, CreatedAt = DateTime.UtcNow, SortOrder = 99,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await vm.ReconcileTickAsync();
|
||||||
|
|
||||||
|
Assert.False(vm.Tasks.Single(t => t.Id == "r1").IsSelected);
|
||||||
|
Assert.True(vm.Tasks.Single(t => t.Id == "r5").IsSelected);
|
||||||
|
Assert.Contains(vm.Tasks, t => t.Id == "header:L1" && t.IsListHeader);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class CountingDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
private sealed class CountingDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||||
{
|
{
|
||||||
private readonly Func<ClaudeDoDbContext> _create;
|
private readonly Func<ClaudeDoDbContext> _create;
|
||||||
|
|||||||
@@ -628,6 +628,42 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
Assert.NotNull(error);
|
Assert.NotNull(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingReviewMergeWorker : StubWorkerClient
|
||||||
|
{
|
||||||
|
public string TaskId { get; } = "review-handler-task";
|
||||||
|
public readonly TaskCompletionSource<(string TaskId, IReadOnlyList<string> Ids, string Phase)> HandoffCall = new();
|
||||||
|
public override Task<string> CreateMergeHelperTaskAsync(
|
||||||
|
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(TaskId);
|
||||||
|
public override Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
|
||||||
|
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
HandoffCall.TrySetResult((taskId, survivingTaskIds, nextPhase));
|
||||||
|
return base.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The review-list "merge the reviews" entry: same host-task shape as the full list handler,
|
||||||
|
// but the pane's launch spec must come from the handoff path with phase "merge" and the
|
||||||
|
// freshly created handler task id — no triage phase first.
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenMergeReviewConPtySessionAsync_AddsPane_LaunchingMergePhaseForHandlerTask()
|
||||||
|
{
|
||||||
|
var worker = new RecordingReviewMergeWorker();
|
||||||
|
using var vm = BuildVm(worker);
|
||||||
|
|
||||||
|
await vm.OpenMergeReviewConPtySessionAsync("L1", new[] { "r1", "r2" });
|
||||||
|
|
||||||
|
var pane = Assert.Single(vm.ConPtySessions);
|
||||||
|
Assert.Equal(worker.TaskId, pane.TaskId);
|
||||||
|
Assert.True(pane.IsTaskBased);
|
||||||
|
|
||||||
|
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(worker.TaskId, call.TaskId);
|
||||||
|
Assert.Equal(new[] { "r1", "r2" }, call.Ids);
|
||||||
|
Assert.Equal("merge", call.Phase);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenMergeHelperHandoffConPtySessionAsync_ExistingPaneForTaskId_ClosesOldPane_OpensNewFocusedPane()
|
public async Task OpenMergeHelperHandoffConPtySessionAsync_ExistingPaneForTaskId_ClosesOldPane_OpensNewFocusedPane()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user