diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index 4cbd50b4..c77dc12b 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -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",
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index 5893d074..6ceb4f4f 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -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",
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
index 40995ad0..3d5cf076 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs
@@ -105,6 +105,25 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids));
}
+ /// Raised once per list after the review-scoped selection dialog is confirmed;
+ /// the shell opens one merge-phase ConPTY tile per request.
+ public event Action? 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();
+ 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)
{
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
index 6a045f9e..df72b56b 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
@@ -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;
/// 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.
@@ -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? 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);
}
diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
index b88640fd..13623fbb 100644
--- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
@@ -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 =>
diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
index 93428487..54479765 100644
--- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
@@ -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 taskIds)
+ public System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList 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 taskIds)
+ => OpenListHandlerConPtySessionAsync(listId, taskIds,
+ "missionControl.mergeReviewsTitle", "missionControl.mergeReviewsTaskTitle",
+ taskId => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, taskIds, "merge"));
+
+ private async System.Threading.Tasks.Task OpenListHandlerConPtySessionAsync(
+ string listId, IReadOnlyList taskIds, string titleKey, string taskTitleKey,
+ Func> 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
{
diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs
index 15cd1fb6..89bdba47 100644
--- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs
@@ -16,6 +16,12 @@ public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
public required string StatusText { get; init; }
public string? ParentTaskId { get; init; }
+ /// The owning list — only set in review mode, where rows span lists.
+ public string ListId { get; init; } = "";
+
+ /// A non-selectable list-name section header (review mode groups rows by list).
+ public bool IsListHeader { get; init; }
+
/// True when the parent is also a candidate and rendered directly above this row.
public bool IsChild { get; init; }
@@ -37,6 +43,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
{
private readonly IDbContextFactory _dbFactory;
private string _listId = "";
+ private bool _reviewMode;
// Parent id -> its child rows, built after load; drives the tick-parent-ticks-children cascade.
private Dictionary> _childrenByParentId = new();
@@ -84,6 +91,25 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
}
+ /// 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.
+ public void ConfigureForReview()
+ {
+ _reviewMode = true;
+ ScopeLabel = Loc.T("modals.mergeHelper.scopeReview");
+ }
+
+ /// Groups a confirmed review-mode selection by owning list, preserving row order —
+ /// the caller starts one merge-phase handler session per group.
+ public IReadOnlyList<(string ListId, IReadOnlyList TaskIds)> GroupSelectionByList(IReadOnlyList 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)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();
}
diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
index 08d090a6..fb9372e7 100644
--- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
+++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
@@ -39,6 +39,10 @@
Command="{Binding LetClaudeHandleCommand}" ToolTip.Tip="{loc:Tr tasks.letClaudeTip}">
+