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:
mika kuns
2026-08-27 08:44:33 +02:00
parent d0c726baa0
commit 741c7ac58d
11 changed files with 293 additions and 8 deletions
@@ -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>
@@ -383,6 +383,115 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
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 readonly Func<ClaudeDoDbContext> _create;
@@ -628,6 +628,42 @@ public class MissionControlViewModelTests : IDisposable
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]
public async Task OpenMergeHelperHandoffConPtySessionAsync_ExistingPaneForTaskId_ClosesOldPane_OpensNewFocusedPane()
{