feat(ui): richer diff viewer + surface child roadblocks on parents
All checks were successful
Changelog / changelog (push) Successful in 1s
Release / release (push) Successful in 38s

- UnifiedDiffParser detects added/deleted/renamed/binary files; diff
  modal shows a file list, binary/empty placeholders, and can diff a
  merged task by commit range after its worktree is gone
- DetailsIslandViewModel flags children needing attention (failed,
  cancelled, awaiting review, or with roadblocks) on the parent
- GitService gains worktree head-commit/range support; planning chain,
  merge orchestration, and session manager tweaks with updated tests
- refresh app/installer/worker icons

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-09 16:40:59 +02:00
parent c300f8c313
commit f21c65be18
28 changed files with 509 additions and 119 deletions

View File

@@ -165,6 +165,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{
OnPropertyChanged(nameof(HasChildOutcomes));
OnPropertyChanged(nameof(ShowMergeSection));
NotifyAttention();
// The Session tab is only visible when it has outcomes; if it just
// emptied while selected, fall back to Output so the body isn't blank.
@@ -354,7 +355,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string? _worktreePath;
[ObservableProperty] private string? _worktreeBaseCommit;
[ObservableProperty] private string? _worktreeHeadCommit;
[ObservableProperty] private string? _worktreeStateLabel;
// Repo working dir of the selected task's list — used to diff a merged task's
// commit range after its worktree directory is gone.
private string? _listWorkingDir;
[ObservableProperty] private string? _branchLine;
[ObservableProperty] private int _turns;
[ObservableProperty] private int _tokens;
@@ -388,6 +393,27 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
public bool HasChildOutcomes => ChildOutcomes.Count > 0;
// Children that need the user's attention before the parent can be approved:
// failed, cancelled, still awaiting their own review, or that reported roadblocks.
// The parent deliberately stays in WaitingForChildren until these are resolved;
// this surfaces a flag so the roadblock is visible on the parent.
public int ChildrenNeedingAttention => ChildOutcomes.Count(c =>
c.Status == ClaudeDo.Data.Models.TaskStatus.Failed
|| c.Status == ClaudeDo.Data.Models.TaskStatus.Cancelled
|| c.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview
|| c.RoadblockCount > 0);
public bool HasChildrenNeedingAttention => ChildrenNeedingAttention > 0;
public string ChildrenAttentionText => ChildrenNeedingAttention == 1
? "1 child needs attention"
: $"{ChildrenNeedingAttention} children need attention";
private void NotifyAttention()
{
OnPropertyChanged(nameof(ChildrenNeedingAttention));
OnPropertyChanged(nameof(HasChildrenNeedingAttention));
OnPropertyChanged(nameof(ChildrenAttentionText));
}
[ObservableProperty] private string _newSubtaskTitle = "";
// Planning merge controls
@@ -840,6 +866,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
EditableDescription = "";
Model = null;
WorktreePath = null;
WorktreeHeadCommit = null;
_listWorkingDir = null;
WorktreeStateLabel = null;
BranchLine = null;
DiffAdditions = 0;
@@ -876,6 +904,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var entity = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Include(t => t.List)
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
ct.ThrowIfCancellationRequested();
if (entity == null) return;
@@ -885,8 +914,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
try { EditableDescription = entity.Description ?? ""; }
finally { _suppressDescSave = false; }
Model = entity.Model;
_listWorkingDir = entity.List?.WorkingDir;
WorktreePath = entity.Worktree?.Path;
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
@@ -914,13 +945,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
{
await LoadPlanningChildrenAsync(row.Id, ct);
}
else
{
await LoadChildOutcomesAsync(row.Id, ct);
}
// Surface every parent's children — planning or improvement — in the
// Session tab with their live status + roadblock count. This is what
// makes the Session tab appear for planning parents and lets a child's
// roadblock register on the parent.
await LoadChildOutcomesAsync(row.Id, ct);
if (entity.Worktree != null
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
@@ -1125,6 +1155,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
row.RoadblockCount = child.RoadblockCount;
row.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
NotifyAttention();
}
catch { /* best-effort */ }
}
@@ -1148,11 +1179,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var entity = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Include(t => t.List)
.FirstOrDefaultAsync(t => t.Id == taskId);
if (entity == null || Task?.Id != taskId) return;
_listWorkingDir = entity.List?.WorkingDir;
WorktreePath = entity.Worktree?.Path;
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
AgentState = StatusToStateKey(entity.Status);
@@ -1190,21 +1224,53 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
private async System.Threading.Tasks.Task OpenDiffAsync()
{
if (WorktreePath == null || ShowDiffModal == null) return;
var diffVm = new DiffModalViewModel(_services.GetRequiredService<ClaudeDo.Data.Git.GitService>())
if (ShowDiffModal == null) return;
var git = _services.GetRequiredService<ClaudeDo.Data.Git.GitService>();
// Active worktree on disk → diff the worktree live (and allow merging from it).
var hasLiveWorktree =
WorktreePath != null
&& WorktreeStateLabel == "Active"
&& System.IO.Directory.Exists(WorktreePath);
DiffModalViewModel diffVm;
if (hasLiveWorktree)
{
WorktreePath = WorktreePath,
BaseRef = WorktreeBaseCommit,
TaskId = Task?.Id,
TaskTitle = Task?.Title ?? "",
ShowMergeModal = ShowMergeModal,
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
};
diffVm = new DiffModalViewModel(git)
{
WorktreePath = WorktreePath!,
BaseRef = WorktreeBaseCommit,
TaskId = Task?.Id,
TaskTitle = Task?.Title ?? "",
ShowMergeModal = ShowMergeModal,
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
};
}
else if (CanDiffMergedRange)
{
// Worktree is gone (merged/discarded) but the commits survive on the
// target branch — diff the captured base..head range in the repo. No
// merge action: the work is already integrated.
diffVm = new DiffModalViewModel(git)
{
WorktreePath = _listWorkingDir!,
BaseRef = WorktreeBaseCommit,
HeadCommit = WorktreeHeadCommit,
FromCommitRange = true,
TaskId = Task?.Id,
TaskTitle = Task?.Title ?? "",
};
}
else return;
await diffVm.LoadAsync();
await ShowDiffModal(diffVm);
}
private bool CanOpenDiff() => WorktreePath != null;
private bool CanDiffMergedRange =>
WorktreeBaseCommit != null && WorktreeHeadCommit != null && _listWorkingDir != null;
private bool CanOpenDiff() => WorktreePath != null || CanDiffMergedRange;
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
private void OpenWorktree()
@@ -1235,6 +1301,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
NotifySessionSections();
}
partial void OnWorktreeHeadCommitChanged(string? value) =>
OpenDiffCommand.NotifyCanExecuteChanged();
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
[RelayCommand]
@@ -1473,7 +1542,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
// hasChildren: conflicts arrive via PlanningMergeConflictEvent → conflict dialog
}
catch { /* stale review action; broadcast reconciles */ }
catch (Exception ex)
{
// A real failure (e.g. a child still needs attention, so the unit can't
// be approved yet) must not vanish — tell the user why nothing happened.
if (ShowErrorAsync != null)
await ShowErrorAsync(ex.Message);
}
}
[RelayCommand]