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 @@ - - - - - - - - - -