diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 0f75cedc..ba68ab2f 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 504a4ffc..8affc2b4 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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", diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index ea823223..fdb3058e 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -143,8 +143,9 @@ public interface IWorkerClient : INotifyPropertyChanged /// 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. - Task GetMergeHelperLaunchSpecAsync(IReadOnlyList 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). + Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default); /// 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. Task CreateMergeHelperTaskAsync( diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index b0230d65..a28a452e 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -627,8 +627,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) => await InvokeTimedAsync("GetAdHocLaunchSpec", () => _hub.InvokeAsync("GetAdHocLaunchSpec", directory, ct)); - public async Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, CancellationToken ct = default) - => await InvokeTimedAsync("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, ct)); + public async Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default) + => await InvokeTimedAsync("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct)); public async Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index cdb6c261..7953b72e 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -17,8 +17,9 @@ namespace ClaudeDo.Ui.ViewModels.Islands; public enum ListKind { Smart, Virtual, User } -/// Confirmed handler run: the scope list and the ordered selected task ids. -public sealed record MergeHelperRequest(string ListId, IReadOnlyList TaskIds); +/// 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). +public sealed record MergeHelperRequest(string ListId, IReadOnlyList 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)); } /// Raised once per list after the review-scoped selection dialog is confirmed; diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index b1ef68c2..8229ca2e 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -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 => { diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 5f769455..11ec8819 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -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 taskIds) + public System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList 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 diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs index c8878362..e8f3bdb5 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs @@ -67,6 +67,15 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase public ObservableCollection Tasks { get; } = new(); + /// 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. + public IReadOnlyList ModelOptions { get; } = new[] { "opus", "fable" }; + + [ObservableProperty] private string _selectedModel = Data.Models.ModelRegistry.HandlerTriageAlias; + + /// Hidden in review mode — those runs start directly at the merge phase, no triage. + 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)); } /// Groups a confirmed review-mode selection by owning list, preserving row order — diff --git a/src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml b/src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml index 0d3f02b7..6f4d72e6 100644 --- a/src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml @@ -29,11 +29,18 @@ - + - - public async Task BuildForMergeHelperAsync(IReadOnlyList 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 . Handoff phases are unaffected. + public async Task BuildForMergeHelperAsync(IReadOnlyList 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 { - "--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, diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 7a219379..21de02b8 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -128,7 +128,7 @@ public abstract class StubWorkerClient : IWorkerClient => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public virtual Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) => Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty(), new Dictionary())); - public virtual Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, CancellationToken ct = default) + public virtual Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default) => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public virtual Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs index d5c44bdd..a3d181d1 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -556,7 +556,7 @@ public class MissionControlViewModelTests : IDisposable private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient { - public override Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, CancellationToken ct = default) + public override Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default) => throw new InvalidOperationException("spec failed"); } diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index e3c500af..1c704ca4 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -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( - () => svc.BuildForMergeHelperAsync(Array.Empty(), listId, Guid.NewGuid().ToString(), CancellationToken.None)); + () => svc.BuildForMergeHelperAsync(Array.Empty(), 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( - () => 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( - () => 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);