feat(ui): triage model picker (opus/fable) in the merge helper start modal

This commit is contained in:
mika kuns
2026-09-10 09:42:55 +02:00
parent 5477ae5302
commit 1177463a82
14 changed files with 84 additions and 36 deletions
@@ -497,6 +497,8 @@
"windowTitle": "Claude machen lassen",
"scopeList": "Liste: {0}",
"scopeReview": "Alle Listen — Tasks im Review",
"modelLabel": "Modell",
"modelTip": "Modell für die Triage-Session (Dedupe & Verfeinern). Spätere Merge-Phasen laufen immer auf Opus.",
"confirm": "Claude machen lassen",
"cancel": "Abbrechen",
"selectAll": "Alle auswählen",
@@ -497,6 +497,8 @@
"windowTitle": "Let Claude handle it",
"scopeList": "List: {0}",
"scopeReview": "All lists — tasks waiting for review",
"modelLabel": "Model",
"modelTip": "Model for the triage session (dedupe & refine). Later merge phases always run on opus.",
"confirm": "Let Claude handle it",
"cancel": "Cancel",
"selectAll": "Select all",
@@ -143,8 +143,9 @@ public interface IWorkerClient : INotifyPropertyChanged
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
/// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.
/// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session
/// can call handoff_list_handler on its own handler task.</summary>
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default);
/// can call handoff_list_handler on its own handler task. model overrides the triage
/// session's model (null = default).</summary>
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default);
/// <summary>Creates the ClaudeDo task that owns a list-handler run (one per run, Idle/IsManual,
/// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary>
Task<string> CreateMergeHelperTaskAsync(
+2 -2
View File
@@ -627,8 +627,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct));
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, ct));
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct));
public async Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
@@ -17,8 +17,9 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public enum ListKind { Smart, Virtual, User }
/// <summary>Confirmed handler run: the scope list and the ordered selected task ids.</summary>
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds);
/// <summary>Confirmed handler run: the scope list, the ordered selected task ids, and the model
/// picked for the triage session (null = default; review-mode runs skip triage entirely).</summary>
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds, string? TriageModel = null);
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{
@@ -114,7 +115,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await vm.LoadAsync();
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
if (ids is { Count: > 0 })
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids));
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids, vm.SelectedModel));
}
/// <summary>Raised once per list after the review-scoped selection dialog is confirmed;
@@ -370,7 +370,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Lists.LetClaudeHandleRequested += req =>
{
OpenMissionControl();
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds);
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds, req.TriageModel);
};
Lists.MergeReviewsRequested += req =>
{
@@ -331,10 +331,10 @@ 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 System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
public System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds, string? triageModel = null)
=> OpenListHandlerConPtySessionAsync(listId, taskIds,
"missionControl.mergeHelperTitle", "missionControl.mergeHelperTaskTitle",
taskId => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId));
taskId => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId, triageModel));
// 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
@@ -67,6 +67,15 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
/// <summary>Model choices for the triage session. Only opus/fable are offered on purpose —
/// triage is the dedupe/refine leverage point; cheaper models aren't worth a picker slot.</summary>
public IReadOnlyList<string> ModelOptions { get; } = new[] { "opus", "fable" };
[ObservableProperty] private string _selectedModel = Data.Models.ModelRegistry.HandlerTriageAlias;
/// <summary>Hidden in review mode — those runs start directly at the merge phase, no triage.</summary>
public bool ShowModelPicker => !_reviewMode;
[ObservableProperty] private string _scopeLabel = "";
public bool HasTasks => Tasks.Count > 0;
@@ -100,6 +109,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
{
_reviewMode = true;
ScopeLabel = Loc.T("modals.mergeHelper.scopeReview");
OnPropertyChanged(nameof(ShowModelPicker));
}
/// <summary>Groups a confirmed review-mode selection by owning list, preserving row order —
@@ -29,11 +29,18 @@
<!-- Body: scope + select-all/none toolbar, headers, checkbox rows -->
<DockPanel>
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto" Margin="20,12,20,6">
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto,Auto" Margin="20,12,20,6">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding ScopeLabel}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectAll}"
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" Margin="0,0,12,0"
IsVisible="{Binding ShowModelPicker}"
ToolTip.Tip="{loc:Tr modals.mergeHelper.modelTip}">
<TextBlock Classes="meta" Text="{loc:Tr modals.mergeHelper.modelLabel}" VerticalAlignment="Center"/>
<ComboBox MinWidth="90" ItemsSource="{Binding ModelOptions}"
SelectedItem="{Binding SelectedModel, Mode=TwoWay}"/>
</StackPanel>
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectAll}"
Command="{Binding SelectAllCommand}" IsEnabled="{Binding HasTasks}"/>
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectNone}"
<Button Grid.Column="3" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectNone}"
Command="{Binding SelectNoneCommand}" IsEnabled="{Binding HasTasks}" Margin="8,0,0,0"/>
</Grid>
+4 -3
View File
@@ -770,12 +770,13 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
// tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label
// and cwd to that list. handlerTaskId is the id returned by CreateMergeHelperTask -- it is
// rendered into the brief so the session can call handoff_list_handler on itself.
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId, string handlerTaskId) => HubGuard(() =>
// rendered into the brief so the session can call handoff_list_handler on itself. model
// overrides the triage session's model (null = default opus).
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId, string handlerTaskId, string? model) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, handlerTaskId, Context.ConnectionAborted);
return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, handlerTaskId, model, Context.ConnectionAborted);
});
// Creates the ClaudeDo task that owns a list-handler run, before the ConPTY tile opens --
@@ -290,8 +290,10 @@ public sealed class InteractiveLaunchSpecService
/// handlerTaskId (the id returned by CreateMergeHelperTaskAsync) is rendered into the brief so
/// the session can call handoff_list_handler/submit_task_for_review on its own handler task.
/// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
/// taskIds is empty or the list has no existing working directory.</summary>
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct)
/// taskIds is empty or the list has no existing working directory.
/// model overrides the triage session's model (start-modal dropdown); null/unknown falls
/// back to <see cref="ModelRegistry.HandlerTriageAlias"/>. Handoff phases are unaffected.</summary>
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model, CancellationToken ct)
{
if (taskIds.Count == 0)
throw new InvalidOperationException("No tasks selected for the list handler.");
@@ -344,12 +346,13 @@ public sealed class InteractiveLaunchSpecService
// longer influences a handler session at all, it would otherwise apply the LIST's effort
// preset to a completely different model.
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
var triageModel = ModelRegistry.TryNormalizeAlias(model) ?? ModelRegistry.HandlerTriageAlias;
var args = new List<string>
{
"--model", ModelRegistry.HandlerTriageAlias,
"--effort", EffortFor(settings, ModelRegistry.HandlerTriageAlias),
"--permission-mode", PermissionModeResolver.Resolve(ModelRegistry.HandlerTriageAlias, "auto"),
"--model", triageModel,
"--effort", EffortFor(settings, triageModel),
"--permission-mode", PermissionModeResolver.Resolve(triageModel, "auto"),
"--allowedTools", MergeHelperAllowedTools,
"--add-dir", sessionDir, repoDir,
"--append-system-prompt-file", systemPromptPath,
+1 -1
View File
@@ -128,7 +128,7 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
@@ -556,7 +556,7 @@ public class MissionControlViewModelTests : IDisposable
private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient
{
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> throw new InvalidOperationException("spec failed");
}
@@ -721,7 +721,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
{
var svc = BuildService(now: now);
var spec = await svc.BuildForMergeHelperAsync(
new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
new[] { taskId }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
return await File.ReadAllTextAsync(Path.Combine(sessionDir, "system-prompt.md"));
}
@@ -851,7 +851,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var listId = await SeedListAsync(workingDir: _tempDir);
var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, Guid.NewGuid().ToString(), CancellationToken.None));
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, Guid.NewGuid().ToString(), null, CancellationToken.None));
}
[Fact]
@@ -863,7 +863,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None));
() => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None));
Assert.Contains("working directory", ex.Message);
}
@@ -876,7 +876,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", Guid.NewGuid().ToString(), CancellationToken.None));
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", Guid.NewGuid().ToString(), null, CancellationToken.None));
}
[Fact]
@@ -892,7 +892,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
Assert.Equal(repo, spec.Cwd);
@@ -934,6 +934,27 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
}
[Theory]
[InlineData("fable", "fable")]
[InlineData("opus", "opus")]
[InlineData("not-a-model", ModelRegistry.HandlerTriageAlias)] // unknown → default, never blocks the launch
public async Task BuildForMergeHelperAsync_ModelOverride_DrivesModelAndEffortArgs(string requested, string expected)
{
var repo = Path.Combine(_tempDir, $"repoModel-{requested}");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), requested, CancellationToken.None);
TrackSessionDir(spec);
var args = spec.Args.ToList();
Assert.Equal(expected, args[args.IndexOf("--model") + 1]);
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, expected).Effort, args[args.IndexOf("--effort") + 1]);
}
// A directory argument that keeps its trailing separator escapes its own closing quote once the
// ConPTY host flattens Args into a single Windows command line ("C:\repo\" -> \" is a literal
// quote), so --add-dir's variadic list swallows every following argument -- including the
@@ -949,7 +970,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var args = spec.Args.ToList();
@@ -972,7 +993,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -999,7 +1020,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var handlerTaskId = Guid.NewGuid().ToString();
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, handlerTaskId, CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, handlerTaskId, null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -1018,7 +1039,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
description: "Do the thing carefully and report back.");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -1036,7 +1057,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "No description task", description: null);
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -1067,7 +1088,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.WaitingForReview, title: "Second task", description: "plain description");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -1402,7 +1423,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var triageSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, CancellationToken.None);
var triageSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, null, CancellationToken.None);
var triageDir = TrackSessionDir(triageSpec);
var executeSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "merge", CancellationToken.None);
var executeDir = TrackSessionDir(executeSpec);
@@ -1453,7 +1474,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir);
var svc = BuildService();
var mergeHelperSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, CancellationToken.None);
var mergeHelperSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, null, CancellationToken.None);
TrackSessionDir(mergeHelperSpec);
var handoffSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None);
TrackSessionDir(handoffSpec);