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": {
|
||||
"showCompletedTip": "Abgeschlossene anzeigen",
|
||||
"letClaudeTip": "Claude machen lassen",
|
||||
"mergeReviewsTip": "Claude die Reviews abnehmen lassen",
|
||||
"quickClaudeTip": "Schnelle Claude-Sitzung",
|
||||
"listSettingsTip": "Listeneinstellungen",
|
||||
"addPlaceholder": "Aufgabe hinzufügen…",
|
||||
@@ -311,6 +312,8 @@
|
||||
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
|
||||
"mergeHelperTaskTitle": "Listen-Handler: {0}",
|
||||
"mergeHelperTaskDescriptionHeader": "Von diesem Lauf bearbeitete Tasks:",
|
||||
"mergeReviewsTitle": "Review-Merge",
|
||||
"mergeReviewsTaskTitle": "Review-Merge: {0}",
|
||||
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
|
||||
"submitForReview": "Zum Review einreichen",
|
||||
"submitForReviewTip": "Diesen Worktree committen und den Task ins Review bringen, damit er gemergt werden kann",
|
||||
@@ -438,6 +441,7 @@
|
||||
"title": "CLAUDE MACHEN LASSEN",
|
||||
"windowTitle": "Claude machen lassen",
|
||||
"scopeList": "Liste: {0}",
|
||||
"scopeReview": "Alle Listen — Tasks im Review",
|
||||
"confirm": "Claude machen lassen",
|
||||
"cancel": "Abbrechen",
|
||||
"selectAll": "Alle auswählen",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"tasks": {
|
||||
"showCompletedTip": "Show completed",
|
||||
"letClaudeTip": "Let Claude handle it",
|
||||
"mergeReviewsTip": "Let Claude merge the reviews",
|
||||
"quickClaudeTip": "Quick Claude session",
|
||||
"listSettingsTip": "List settings",
|
||||
"addPlaceholder": "Add a task…",
|
||||
@@ -311,6 +312,8 @@
|
||||
"mergeHelperHandoffTitleSuffix": " (Handoff)",
|
||||
"mergeHelperTaskTitle": "List handler: {0}",
|
||||
"mergeHelperTaskDescriptionHeader": "Tasks handled by this run:",
|
||||
"mergeReviewsTitle": "Review Merge",
|
||||
"mergeReviewsTaskTitle": "Review merge: {0}",
|
||||
"submitForReviewFailed": "Couldn't submit for review: {0}",
|
||||
"submitForReview": "Submit for review",
|
||||
"submitForReviewTip": "Commit this worktree and move the task to review so it can be merged",
|
||||
@@ -438,6 +441,7 @@
|
||||
"title": "LET CLAUDE HANDLE IT",
|
||||
"windowTitle": "Let Claude handle it",
|
||||
"scopeList": "List: {0}",
|
||||
"scopeReview": "All lists — tasks waiting for review",
|
||||
"confirm": "Let Claude handle it",
|
||||
"cancel": "Cancel",
|
||||
"selectAll": "Select all",
|
||||
|
||||
@@ -105,6 +105,25 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
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]
|
||||
private void OpenInExplorer(ListNavItemViewModel? row)
|
||||
{
|
||||
|
||||
@@ -124,6 +124,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
|
||||
private bool _isLetClaudeVisible;
|
||||
[ObservableProperty] private bool _isQuickClaudeVisible;
|
||||
[ObservableProperty] private bool _isMergeReviewsVisible;
|
||||
|
||||
/// <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>
|
||||
@@ -147,6 +148,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
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
|
||||
// Mission Control, so this just raises an event for it to act on (mirrors OpenConPtySessionRequested).
|
||||
public event Action<string>? OpenQuickClaudeSessionRequested;
|
||||
@@ -411,7 +419,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
ShowOpenLabel = false;
|
||||
ShowNotesRow = false;
|
||||
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;
|
||||
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";
|
||||
IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
|
||||
IsQuickClaudeVisible = IsLetClaudeVisible;
|
||||
IsMergeReviewsVisible = list.Id == "virtual:review";
|
||||
|
||||
LoadTask = LoadForListAsync(list, ct, reusable);
|
||||
}
|
||||
|
||||
@@ -357,6 +357,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
OpenMissionControl();
|
||||
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds);
|
||||
};
|
||||
Lists.MergeReviewsRequested += req =>
|
||||
{
|
||||
OpenMissionControl();
|
||||
_ = MissionControl.OpenMergeReviewConPtySessionAsync(req.ListId, req.TaskIds);
|
||||
};
|
||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||
Tasks.OpenListSettingsRequested += (_, _) =>
|
||||
{
|
||||
@@ -368,6 +373,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
if (Lists.SelectedList is { } row)
|
||||
Lists.LetClaudeHandleListCommand.Execute(row);
|
||||
};
|
||||
Tasks.MergeReviewsRequested += (_, _) => Lists.MergeReviewsCommand.Execute(null);
|
||||
Details.ErrorReported += FlashFooterError;
|
||||
Details.CloseDetail = () => Tasks.SelectFrom(null, "close-detail");
|
||||
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
|
||||
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
|
||||
// (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;
|
||||
// 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
|
||||
{
|
||||
var title = Loc.T("missionControl.mergeHelperTitle");
|
||||
var title = Loc.T(titleKey);
|
||||
var listName = listId;
|
||||
try
|
||||
{
|
||||
@@ -263,7 +278,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
try
|
||||
{
|
||||
taskId = await _worker.CreateMergeHelperTaskAsync(taskIds, listId,
|
||||
Loc.T("missionControl.mergeHelperTaskTitle", listName),
|
||||
Loc.T(taskTitleKey, listName),
|
||||
Loc.T("missionControl.mergeHelperTaskDescriptionHeader"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -279,7 +294,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId))));
|
||||
() => DescribeAsync(() => launchSpec(taskId))));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
Command="{Binding LetClaudeHandleCommand}" ToolTip.Tip="{loc:Tr tasks.letClaudeTip}">
|
||||
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.AgentSuggested}"/>
|
||||
</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}"
|
||||
Command="{Binding OpenQuickClaudeSessionCommand}" ToolTip.Tip="{loc:Tr tasks.quickClaudeTip}">
|
||||
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.ArrowOut}"/>
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:MergeHelperTaskRowViewModel">
|
||||
<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 -->
|
||||
<Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
|
||||
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
|
||||
@@ -60,8 +64,10 @@
|
||||
</Border>
|
||||
<CheckBox Grid.Column="1" MinWidth="0"
|
||||
IsChecked="{Binding IsSelected, Mode=TwoWay}"
|
||||
IsVisible="{Binding !IsListHeader}"
|
||||
VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4"
|
||||
IsVisible="{Binding !IsListHeader}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Classes="body" Text="{Binding Title}"
|
||||
VerticalAlignment="Center" Margin="4,0,0,0"
|
||||
@@ -73,6 +79,7 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<Border Grid.Column="3" Classes="chip"
|
||||
IsVisible="{Binding !IsListHeader}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding StatusText}"/>
|
||||
</Border>
|
||||
|
||||
Reference in New Issue
Block a user