diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md
index eecd633..e59e824 100644
--- a/src/ClaudeDo.Ui/CLAUDE.md
+++ b/src/ClaudeDo.Ui/CLAUDE.md
@@ -22,7 +22,7 @@ ViewModels/
Modals/ — About, Diff, ListSettings, Merge, RepoImport, Settings (+ Settings/ tab VMs),
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree,
WorktreesOverview, UnifiedDiffParser
- Planning/ — PlanningDiffViewModel, ConflictResolutionViewModel
+ Planning/ — PlanningDiffViewModel
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
@@ -41,7 +41,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, per-list Model/SystemPrompt/AgentPath/MaxTurns with inherited-badge + reset, delete list), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`.
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModalViewModel` has two modes: live worktree (branch diff vs base, with a Merge action) and commit-range `base..head` (`FromCommitRange = true` — shows a merged task's diff after its worktree is gone; no merge action). The view renders a file list with binary/empty placeholders via `DiffLinesView`.
-- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolutionViewModel` (unit-merge conflict dialog: conflicted files, open in VS Code, continue/abort planning merge), `ConflictResolverViewModel` (in-app **3-way merge editor** for single-task merges: starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`, flattens conflicts for one-at-a-time navigation, per-block Accept Ours/Base/Theirs/Both + editable result, composes each file and writes via `WriteConflictResolution`, continue/abort). The view (`Views/Conflicts/ConflictResolverView`) renders a 3-column Base|Ours|Theirs + result using **AvaloniaEdit** with TextMate syntax highlighting by file extension (theme `StyleInclude` in `App.axaml`); editors are synced in code-behind since AvaloniaEdit's `Text` isn't cleanly bindable.
+- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolverViewModel` (in-app **3-way merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`, flattens conflicts for one-at-a-time navigation, per-block Accept Ours/Base/Theirs/Both + editable result, composes each file and writes via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) renders a 3-column Base|Ours|Theirs + result using **AvaloniaEdit** with TextMate syntax highlighting by file extension (theme `StyleInclude` in `App.axaml`); editors are synced in code-behind since AvaloniaEdit's `Text` isn't cleanly bindable.
## Services
diff --git a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
index 94c2808..7212e24 100644
--- a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
@@ -75,4 +75,16 @@ public sealed class MergeFile
/// Reassemble the file: stable text verbatim, each conflict replaced by its resolution.
public string Compose() => string.Concat(
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? s.Conflict.Ours) : s.StableText));
+
+ /// Left pane document: stable regions verbatim, conflict regions show Ours text.
+ public string OursText => string.Concat(
+ Segments.Select(s => s.IsConflict ? s.Conflict!.Ours : s.StableText));
+
+ /// Right pane document: stable regions verbatim, conflict regions show Theirs text.
+ public string TheirsText => string.Concat(
+ Segments.Select(s => s.IsConflict ? s.Conflict!.Theirs : s.StableText));
+
+ /// Middle (result) pane document: stable regions verbatim, conflict regions show Resolution if set, else Ours.
+ public string ResultText => string.Concat(
+ Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? s.Conflict.Ours) : s.StableText));
}
diff --git a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs
index 9434a7a..06548e8 100644
--- a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs
@@ -15,6 +15,13 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
private readonly IWorkerClient _worker;
private readonly string _taskId;
+ // The task whose conflicted working tree is read/written. For a single-task merge this is
+ // _taskId; for a planning unit-merge it's the subtask currently being merged.
+ private string _conflictTaskId;
+
+ // When set, this is a planning unit-merge: continue/abort drive the orchestrator on the parent.
+ private string? _planningParentId;
+
public ObservableCollection Files { get; } = new();
// All text conflicts across all files, flattened for one-at-a-time navigation.
@@ -38,14 +45,39 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
[NotifyCanExecuteChangedFor(nameof(PreviousCommand))]
private int _currentIndex = -1;
+ [ObservableProperty] private MergeFile? _activeFile;
+
+ /// Raised when the active file changes so the view can rebuild its three documents.
+ public event Action? ActiveFileChanged;
+
+ partial void OnActiveFileChanged(MergeFile? value)
+ {
+ ActiveFileChanged?.Invoke();
+ OnPropertyChanged(nameof(ActiveOursText));
+ OnPropertyChanged(nameof(ActiveTheirsText));
+ OnPropertyChanged(nameof(ActiveResultText));
+ OnPropertyChanged(nameof(PositionText));
+ }
+
+ public string ActiveOursText => ActiveFile?.OursText ?? "";
+ public string ActiveTheirsText => ActiveFile?.TheirsText ?? "";
+ public string ActiveResultText => ActiveFile?.ResultText ?? "";
+
public bool HasCurrent => Current is not null;
public int TotalConflicts => _flat.Count;
public int ResolvedCount => _flat.Count(x => x.Block.IsResolved);
public string? CurrentPath => InRange ? _flat[CurrentIndex].File.Path : null;
- public string PositionText => _flat.Count == 0
- ? "No text conflicts"
- : $"Conflict {CurrentIndex + 1} of {_flat.Count} · {ResolvedCount} resolved";
+ public string PositionText
+ {
+ get
+ {
+ if (ActiveFile is null || ActiveFile.Conflicts.Count == 0) return "No text conflicts";
+ var count = ActiveFile.Conflicts.Count;
+ var resolved = ActiveFile.Conflicts.Count(c => c.IsResolved);
+ return $"{count} conflicts · {resolved} resolved";
+ }
+ }
public IReadOnlyList BinaryFilePaths => Files.Where(f => f.IsBinary).Select(f => f.Path).ToList();
public bool HasBinaryFiles => Files.Any(f => f.IsBinary);
@@ -66,6 +98,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
{
_worker = worker;
_taskId = taskId;
+ _conflictTaskId = taskId;
}
/// Starts the conflict merge and loads the conflicted files as line-level segments.
@@ -83,26 +116,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
Error = start.ErrorMessage;
return false;
}
-
- var docs = await _worker.GetMergeConflictDocumentsAsync(_taskId);
- Files.Clear();
- _flat.Clear();
- foreach (var f in docs.Files)
- {
- var segments = f.Segments.Select(s => s.IsConflict
- ? MergeFileSegment.FromConflict(Hook(new MergeConflictBlock(s.Ours, s.Base, s.Theirs)))
- : MergeFileSegment.Stable(s.Text)).ToList();
- var file = new MergeFile(f.Path, f.IsBinary, segments);
- Files.Add(file);
- foreach (var c in file.Conflicts) _flat.Add((file, c));
- }
-
- OnPropertyChanged(nameof(TotalConflicts));
- OnPropertyChanged(nameof(BinaryFilePaths));
- OnPropertyChanged(nameof(HasBinaryFiles));
- RecomputeCanContinue();
- if (_flat.Count > 0) MoveTo(0);
- return Files.Count > 0;
+ return await LoadDocumentsAsync();
}
catch (Exception ex)
{
@@ -112,6 +126,53 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
finally { IsBusy = false; }
}
+ /// Resolves a planning unit-merge conflict for . The merge is
+ /// already mid-conflict (driven by the orchestrator), so this only loads the conflicted files;
+ /// continue/abort hand back to the orchestrator on .
+ public async Task OpenForPlanningAsync(string planningParentId, string subtaskId)
+ {
+ _planningParentId = planningParentId;
+ _conflictTaskId = subtaskId;
+ IsBusy = true;
+ Error = null;
+ try
+ {
+ return await LoadDocumentsAsync();
+ }
+ catch (Exception ex)
+ {
+ Error = ex.Message;
+ return false;
+ }
+ finally { IsBusy = false; }
+ }
+
+ private async Task LoadDocumentsAsync()
+ {
+ var docs = await _worker.GetMergeConflictDocumentsAsync(_conflictTaskId);
+ Files.Clear();
+ _flat.Clear();
+ foreach (var f in docs.Files)
+ {
+ var segments = f.Segments.Select(s => s.IsConflict
+ ? MergeFileSegment.FromConflict(Hook(new MergeConflictBlock(s.Ours, s.Base, s.Theirs)))
+ : MergeFileSegment.Stable(s.Text)).ToList();
+ var file = new MergeFile(f.Path, f.IsBinary, segments);
+ Files.Add(file);
+ foreach (var c in file.Conflicts) _flat.Add((file, c));
+ }
+
+ OnPropertyChanged(nameof(TotalConflicts));
+ OnPropertyChanged(nameof(BinaryFilePaths));
+ OnPropertyChanged(nameof(HasBinaryFiles));
+ RecomputeCanContinue();
+ if (_flat.Count > 0)
+ MoveTo(0); // also sets ActiveFile via MoveTo
+ else if (Files.Count > 0)
+ ActiveFile = Files[0];
+ return Files.Count > 0;
+ }
+
private MergeConflictBlock Hook(MergeConflictBlock block)
{
block.PropertyChanged += OnBlockChanged;
@@ -125,6 +186,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
RecomputeCanContinue();
OnPropertyChanged(nameof(ResolvedCount));
OnPropertyChanged(nameof(PositionText));
+ OnPropertyChanged(nameof(ActiveResultText));
}
}
@@ -135,10 +197,22 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
{
CurrentIndex = index;
Current = _flat[index].Block;
+ ActiveFile = _flat[index].File;
OnPropertyChanged(nameof(CurrentPath));
CurrentChanged?.Invoke();
}
+ [RelayCommand]
+ private void SelectFile(MergeFile file)
+ {
+ // Jump to the first conflict in this file (if any); otherwise just switch the active file.
+ var idx = _flat.FindIndex(x => x.File == file);
+ if (idx >= 0)
+ MoveTo(idx);
+ else
+ ActiveFile = file;
+ }
+
private bool CanGoNext() => CurrentIndex >= 0 && CurrentIndex < _flat.Count - 1;
private bool CanGoPrevious() => CurrentIndex > 0;
@@ -157,7 +231,16 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
try
{
foreach (var file in Files.Where(f => !f.IsBinary))
- await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.Compose());
+ await _worker.WriteConflictResolutionAsync(_conflictTaskId, file.Path, file.Compose());
+
+ if (_planningParentId is not null)
+ {
+ // Hand back to the orchestrator: it commits this subtask and drains the rest.
+ // A later subtask conflict re-opens this editor via the PlanningMergeConflict broadcast.
+ await _worker.ContinuePlanningMergeAsync(_planningParentId);
+ CloseRequested?.Invoke();
+ return;
+ }
var result = await _worker.ContinueConflictMergeAsync(_taskId);
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
@@ -176,7 +259,13 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
private async Task AbortAsync()
{
IsBusy = true;
- try { await _worker.AbortConflictMergeAsync(_taskId); }
+ try
+ {
+ if (_planningParentId is not null)
+ await _worker.AbortPlanningMergeAsync(_planningParentId);
+ else
+ await _worker.AbortConflictMergeAsync(_taskId);
+ }
catch (Exception ex) { Error = ex.Message; }
finally
{
diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
index fdf4dd3..41287d4 100644
--- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
@@ -41,9 +41,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
public Func ResolveMergeVm => _mergeVmFactory;
- // Set by MainWindow to open the conflict resolution dialog.
- public Func? ShowConflictDialog { get; set; }
-
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
public Func? ConflictResolverFactory { get; set; }
@@ -146,44 +143,17 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList conflictedFiles)
{
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
- _ = OpenConflictDialogAsync(planningTaskId, subtaskId, conflictedFiles);
+ // A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
+ _ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
}
- private async Task OpenConflictDialogAsync(string planningTaskId, string subtaskId, IReadOnlyList conflictedFiles)
+ private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
{
- if (ShowConflictDialog == null || _dbFactory == null) return;
-
- string subtaskTitle = subtaskId;
- // The conflict lives in the list's working dir (the repo being merged into),
- // not the subtask worktree. VS Code must open this folder to show the merge UI.
- string repoDirectory = System.Environment.CurrentDirectory;
- string targetBranch = Worker?.LastApproveTarget ?? "main";
-
- try
- {
- await using var ctx = await _dbFactory.CreateDbContextAsync();
- var entity = await ctx.Tasks
- .Include(t => t.List)
- .AsNoTracking()
- .FirstOrDefaultAsync(t => t.Id == subtaskId);
- if (entity != null)
- {
- subtaskTitle = entity.Title;
- if (entity.List?.WorkingDir is { } dir && !string.IsNullOrWhiteSpace(dir))
- repoDirectory = dir;
- }
- }
- catch { /* Non-fatal: fall back to subtaskId and cwd */ }
-
- var vm = new ConflictResolutionViewModel(
- Worker!,
- planningTaskId,
- subtaskTitle,
- targetBranch,
- conflictedFiles,
- repoDirectory);
-
- await ShowConflictDialog(vm);
+ if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
+ var vm = ConflictResolverFactory(subtaskId);
+ var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId);
+ if (hasConflicts)
+ await ShowConflictResolver(vm);
}
// For tests only — does NOT wire up events.
diff --git a/src/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cs
deleted file mode 100644
index 08ea33e..0000000
--- a/src/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-using System.Diagnostics;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using ClaudeDo.Ui.Localization;
-using ClaudeDo.Ui.Services;
-
-namespace ClaudeDo.Ui.ViewModels.Planning;
-
-public sealed partial class ConflictResolutionViewModel : ObservableObject
-{
- private readonly IWorkerClient _worker;
- private readonly string _planningTaskId;
- // The repository directory that is currently mid-merge (the list's working dir),
- // NOT the subtask worktree. Opening this folder is what makes VS Code show its
- // merge-conflict resolution UI.
- private readonly string _repoDirectory;
-
- public string SubtaskTitle { get; }
- public string TargetBranch { get; }
- public IReadOnlyList ConflictedFiles { get; }
- public string SubtaskLabel => Loc.T("vm.conflictResolution.subtaskPrefix", SubtaskTitle);
- public string TargetLabel => Loc.T("vm.conflictResolution.targetPrefix", TargetBranch);
-
- [ObservableProperty] private string? _vsCodeError;
- [ObservableProperty] private string? _actionError;
-
- public Action? CloseRequested { get; set; }
-
- public ConflictResolutionViewModel(
- IWorkerClient worker,
- string planningTaskId,
- string subtaskTitle,
- string targetBranch,
- IReadOnlyList conflictedFiles,
- string repoDirectory)
- {
- _worker = worker;
- _planningTaskId = planningTaskId;
- _repoDirectory = repoDirectory;
- SubtaskTitle = subtaskTitle;
- TargetBranch = targetBranch;
- ConflictedFiles = conflictedFiles;
- }
-
- [RelayCommand]
- private void OpenInVsCode()
- {
- try
- {
- // Open the folder that is mid-merge so VS Code shows the Source Control
- // merge-conflict UI for every conflicted file. Opening individual files
- // gives only a plain editor with no conflict resolution affordances.
- Process.Start(new ProcessStartInfo
- {
- FileName = "code",
- Arguments = $"\"{_repoDirectory}\"",
- UseShellExecute = true,
- });
- VsCodeError = null;
- }
- catch (Exception ex)
- {
- VsCodeError = Loc.T("vm.conflictResolution.vsCodeError", ex.Message);
- }
- }
-
- [RelayCommand]
- private async Task ContinueAsync()
- {
- ActionError = null;
- try
- {
- await _worker.ContinuePlanningMergeAsync(_planningTaskId);
- CloseRequested?.Invoke();
- }
- catch (Exception ex) { ActionError = ex.Message; }
- }
-
- [RelayCommand]
- private async Task AbortAsync()
- {
- ActionError = null;
- try
- {
- await _worker.AbortPlanningMergeAsync(_planningTaskId);
- CloseRequested?.Invoke();
- }
- catch (Exception ex) { ActionError = ex.Message; }
- }
-}
diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
index e81728b..f340dad 100644
--- a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
@@ -42,11 +42,6 @@ public partial class MainWindow : Window
{
if (DataContext is IslandsShellViewModel vm)
{
- vm.ShowConflictDialog = async (conflictVm) =>
- {
- var modal = new ConflictResolutionView { DataContext = conflictVm };
- await modal.ShowDialog(this);
- };
vm.ShowAboutModal = async (aboutVm) =>
{
var dlg = new AboutModalView { DataContext = aboutVm };
diff --git a/src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml b/src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml
deleted file mode 100644
index f08bb34..0000000
--- a/src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml.cs b/src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml.cs
deleted file mode 100644
index 16f4a99..0000000
--- a/src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using Avalonia.Controls;
-using ClaudeDo.Ui.ViewModels.Planning;
-
-namespace ClaudeDo.Ui.Views.Planning;
-
-public partial class ConflictResolutionView : Window
-{
- public ConflictResolutionView()
- {
- InitializeComponent();
- }
-
- protected override void OnDataContextChanged(EventArgs e)
- {
- base.OnDataContextChanged(e);
- if (DataContext is ConflictResolutionViewModel vm)
- vm.CloseRequested = Close;
- }
-}
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolutionViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolutionViewModelTests.cs
deleted file mode 100644
index f8cd25c..0000000
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolutionViewModelTests.cs
+++ /dev/null
@@ -1,104 +0,0 @@
-using ClaudeDo.Data.Models;
-using ClaudeDo.Ui.Services;
-using ClaudeDo.Ui.ViewModels.Planning;
-
-namespace ClaudeDo.Ui.Tests.ViewModels;
-
-public class ConflictResolutionViewModelTests
-{
- // ------------------------------------------------------------------ fake
- private sealed class FakeWorker : StubWorkerClient
- {
- public string? ContinueCalledWith { get; private set; }
- public string? AbortCalledWith { get; private set; }
- public Exception? ContinueThrows { get; set; }
- public Exception? AbortThrows { get; set; }
-
- public override Task ContinuePlanningMergeAsync(string planningTaskId)
- {
- ContinueCalledWith = planningTaskId;
- if (ContinueThrows is not null) throw ContinueThrows;
- return Task.CompletedTask;
- }
-
- public override Task AbortPlanningMergeAsync(string planningTaskId)
- {
- AbortCalledWith = planningTaskId;
- if (AbortThrows is not null) throw AbortThrows;
- return Task.CompletedTask;
- }
- }
-
- private static ConflictResolutionViewModel BuildVm(FakeWorker worker, string planningTaskId = "plan-1") =>
- new ConflictResolutionViewModel(
- worker,
- planningTaskId,
- subtaskTitle: "My subtask",
- targetBranch: "main",
- conflictedFiles: new[] { "src/Foo.cs", "src/Bar.cs" },
- repoDirectory: "C:/repos/plan-1");
-
- // ------------------------------------------------------------------ tests
-
- [Fact]
- public async Task ContinueAsync_CallsHub_AndClosesOnSuccess()
- {
- var worker = new FakeWorker();
- var vm = BuildVm(worker, "plan-42");
- bool closeCalled = false;
- vm.CloseRequested = () => closeCalled = true;
-
- await vm.ContinueCommand.ExecuteAsync(null);
-
- Assert.Equal("plan-42", worker.ContinueCalledWith);
- Assert.True(closeCalled);
- Assert.Null(vm.ActionError);
- }
-
- [Fact]
- public async Task ContinueAsync_HubThrows_ShowsActionErrorAndStaysOpen()
- {
- var worker = new FakeWorker { ContinueThrows = new InvalidOperationException("hub down") };
- var vm = BuildVm(worker);
- bool closeCalled = false;
- vm.CloseRequested = () => closeCalled = true;
-
- await vm.ContinueCommand.ExecuteAsync(null);
-
- Assert.False(closeCalled);
- Assert.Equal("hub down", vm.ActionError);
- }
-
- [Fact]
- public async Task AbortAsync_CallsHub_AndClosesOnSuccess()
- {
- var worker = new FakeWorker();
- var vm = BuildVm(worker, "plan-99");
- bool closeCalled = false;
- vm.CloseRequested = () => closeCalled = true;
-
- await vm.AbortCommand.ExecuteAsync(null);
-
- Assert.Equal("plan-99", worker.AbortCalledWith);
- Assert.True(closeCalled);
- Assert.Null(vm.ActionError);
- }
-
- [Fact]
- public async Task AbortAsync_HubThrows_ShowsActionError()
- {
- var worker = new FakeWorker { AbortThrows = new InvalidOperationException("abort failed") };
- var vm = BuildVm(worker);
- bool closeCalled = false;
- vm.CloseRequested = () => closeCalled = true;
-
- await vm.AbortCommand.ExecuteAsync(null);
-
- Assert.False(closeCalled);
- Assert.Equal("abort failed", vm.ActionError);
- }
-
- // OpenInVsCode is not unit-tested here because abstracting Process.Start
- // would require an indirection layer that isn't part of the approved design.
- // The error path is covered by the VsCodeError property being set on catch.
-}
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs
index 5fe3a22..6839823 100644
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs
@@ -19,22 +19,39 @@ public class ConflictResolverViewModelTests
private sealed class FakeWorker : StubWorkerClient
{
+ public string? WrittenTaskId;
public string? WrittenPath;
public string? WrittenContent;
public bool Continued;
public bool Aborted;
public string ContinueStatus = "merged";
public IReadOnlyList Docs = new[] { OneConflictFile() };
+ public bool StartCalled;
+ public string? ContinuedPlanningParent;
+ public string? AbortedPlanningParent;
public override Task StartConflictMergeAsync(string taskId, string targetBranch)
- => Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
+ {
+ StartCalled = true;
+ return Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
+ }
+
+ public override Task ContinuePlanningMergeAsync(string planningTaskId)
+ {
+ ContinuedPlanningParent = planningTaskId; return Task.CompletedTask;
+ }
+
+ public override Task AbortPlanningMergeAsync(string planningTaskId)
+ {
+ AbortedPlanningParent = planningTaskId; return Task.CompletedTask;
+ }
public override Task GetMergeConflictDocumentsAsync(string taskId)
=> Task.FromResult(new MergeConflictDocumentsDto(taskId, Docs));
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
{
- WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
+ WrittenTaskId = taskId; WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
}
public override Task ContinueConflictMergeAsync(string taskId)
@@ -160,4 +177,137 @@ public class ConflictResolverViewModelTests
Assert.True(worker.Aborted);
Assert.True(closed);
}
+
+ [Fact]
+ public async Task OpenForPlanning_LoadsConflicts_WithoutStartingAMerge()
+ {
+ var worker = new FakeWorker();
+ var vm = new ConflictResolverViewModel(worker, "parent-1");
+
+ var hasConflicts = await vm.OpenForPlanningAsync("parent-1", "subtask-7");
+
+ Assert.True(hasConflicts);
+ Assert.False(worker.StartCalled); // the orchestrator already started the merge
+ Assert.Equal(1, vm.TotalConflicts);
+ }
+
+ [Fact]
+ public async Task PlanningContinue_HandsBackToOrchestrator_AndCloses()
+ {
+ var worker = new FakeWorker();
+ var vm = new ConflictResolverViewModel(worker, "parent-1");
+ var closed = false;
+ vm.CloseRequested = () => closed = true;
+
+ await vm.OpenForPlanningAsync("parent-1", "subtask-7");
+ vm.Current!.AcceptOursCommand.Execute(null);
+ await vm.ContinueCommand.ExecuteAsync(null);
+
+ Assert.Equal("subtask-7", worker.WrittenTaskId); // resolution written against the subtask's working tree
+ Assert.Equal("parent-1", worker.ContinuedPlanningParent);
+ Assert.False(worker.Continued); // single-task continue must NOT be used in planning mode
+ Assert.True(closed);
+ }
+
+ [Fact]
+ public async Task PlanningAbort_AbortsThePlanningMerge()
+ {
+ var worker = new FakeWorker();
+ var vm = new ConflictResolverViewModel(worker, "parent-1");
+
+ await vm.OpenForPlanningAsync("parent-1", "subtask-7");
+ await vm.AbortCommand.ExecuteAsync(null);
+
+ Assert.Equal("parent-1", worker.AbortedPlanningParent);
+ Assert.False(worker.Aborted); // single-task abort must NOT be used in planning mode
+ }
+
+ // ── Task 1 new tests ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void OursTheirsResult_Reconstruction()
+ {
+ // stable "a\n" + conflict(ours "o\n", base null, theirs "t\n") + stable "z\n"
+ var segments = new[]
+ {
+ MergeFileSegment.Stable("a\n"),
+ MergeFileSegment.FromConflict(new MergeConflictBlock("o\n", null, "t\n")),
+ MergeFileSegment.Stable("z\n"),
+ };
+ var file = new MergeFile("f.cs", false, segments);
+
+ Assert.Equal("a\no\nz\n", file.OursText);
+ Assert.Equal("a\nt\nz\n", file.TheirsText);
+ Assert.Equal("a\no\nz\n", file.ResultText); // unresolved seeds Ours
+
+ // After resolving: ResultText reflects the resolution
+ file.Conflicts[0].Resolution = "r\n";
+ Assert.Equal("a\nr\nz\n", file.ResultText);
+ }
+
+ [Fact]
+ public async Task ActiveFile_DefaultsToFirstFile_AfterOpen()
+ {
+ var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
+
+ await vm.OpenAsync("main");
+
+ Assert.NotNull(vm.ActiveFile);
+ Assert.Equal("README.md", vm.ActiveFile!.Path);
+ }
+
+ [Fact]
+ public async Task SelectFile_PreservesResolution_AndUpdatesPassThroughs()
+ {
+ // Two-file setup
+ var worker = new FakeWorker
+ {
+ Docs = new[]
+ {
+ new ConflictDocumentDto("a.cs", false, new[]
+ {
+ new MergeSegmentDto(false, "a\n", "", null, ""),
+ new MergeSegmentDto(true, "", "ours-a\n", null, "theirs-a\n"),
+ new MergeSegmentDto(false, "z\n", "", null, ""),
+ }),
+ new ConflictDocumentDto("b.cs", false, new[]
+ {
+ new MergeSegmentDto(true, "", "ours-b\n", null, "theirs-b\n"),
+ }),
+ },
+ };
+ var vm = new ConflictResolverViewModel(worker, "task-1");
+ await vm.OpenAsync("main");
+
+ // Resolve the block in file A
+ var fileA = vm.Files[0];
+ fileA.Conflicts[0].Resolution = "resolved-a\n";
+
+ // Switch to file B
+ vm.SelectFileCommand.Execute(vm.Files[1]);
+ Assert.Equal("b.cs", vm.ActiveFile!.Path);
+ Assert.Equal("ours-b\n", vm.ActiveResultText); // unresolved seeds Ours
+
+ // Switch back to file A
+ vm.SelectFileCommand.Execute(vm.Files[0]);
+ Assert.Equal("a.cs", vm.ActiveFile!.Path);
+ // Resolution survived the round-trip
+ Assert.Equal("a\nresolved-a\nz\n", vm.ActiveResultText);
+ Assert.Equal("a\nours-a\nz\n", vm.ActiveOursText);
+ }
+
+ [Fact]
+ public async Task PositionText_ReadsActiveFileConflicts()
+ {
+ var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
+ await vm.OpenAsync("main");
+
+ // 1 conflict, 0 resolved
+ Assert.Equal("1 conflicts · 0 resolved", vm.PositionText);
+
+ vm.Current!.AcceptOursCommand.Execute(null);
+
+ // 1 conflict, 1 resolved
+ Assert.Equal("1 conflicts · 1 resolved", vm.PositionText);
+ }
}