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}"> +