Implements Task 14: PlanningDiffView (Window), PlanningDiffViewModel, ShowPlanningDiffModal callback wired in DetailsIslandView, and 5 xUnit tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
821 lines
31 KiB
C#
821 lines
31 KiB
C#
using System.Collections.ObjectModel;
|
||
using System.Text;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using ClaudeDo.Data;
|
||
using ClaudeDo.Data.Models;
|
||
using ClaudeDo.Data.Repositories;
|
||
using ClaudeDo.Ui.Helpers;
|
||
using ClaudeDo.Ui.Services;
|
||
using ClaudeDo.Ui.ViewModels.Modals;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
|
||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||
|
||
public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||
{
|
||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||
private readonly IWorkerClient _worker;
|
||
private readonly IServiceProvider _services;
|
||
|
||
// Current task row (set by IslandsShellViewModel via Bind)
|
||
[ObservableProperty]
|
||
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
|
||
private TaskRowViewModel? _task;
|
||
|
||
// Editable fields
|
||
[ObservableProperty] private string _editableTitle = "";
|
||
[ObservableProperty] private string _notes = "";
|
||
[ObservableProperty] private string _promptInput = "";
|
||
|
||
// Short task-id badge, e.g. "#T1A"
|
||
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
||
|
||
// Agent strip fields
|
||
[ObservableProperty]
|
||
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
|
||
private string _agentStatusLabel = "Idle";
|
||
public bool IsRunning => AgentStatusLabel == "Running";
|
||
public bool IsDone => AgentStatusLabel == "Done";
|
||
public bool IsFailed => AgentStatusLabel == "Failed";
|
||
|
||
[ObservableProperty]
|
||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||
[NotifyCanExecuteChangedFor(nameof(ResetCommand))]
|
||
private bool _showFailedActions;
|
||
|
||
[ObservableProperty]
|
||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||
private string? _latestRunSessionId;
|
||
|
||
partial void OnAgentStatusLabelChanged(string value)
|
||
{
|
||
OnPropertyChanged(nameof(IsRunning));
|
||
OnPropertyChanged(nameof(IsDone));
|
||
OnPropertyChanged(nameof(IsFailed));
|
||
OnPropertyChanged(nameof(IsAgentSectionEnabled));
|
||
ShowFailedActions = value == "Failed";
|
||
}
|
||
[ObservableProperty] private string? _model;
|
||
|
||
// Agent settings overrides
|
||
[ObservableProperty] private string _taskModelSelection = "(inherit)";
|
||
[ObservableProperty] private string _taskSystemPrompt = "";
|
||
[ObservableProperty] private AgentInfo? _taskSelectedAgent;
|
||
|
||
[ObservableProperty] private string _effectiveModelHint = "";
|
||
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
||
[ObservableProperty] private string _effectiveAgentHint = "";
|
||
|
||
public System.Collections.ObjectModel.ObservableCollection<string> TaskModelOptions { get; } = new()
|
||
{
|
||
"(inherit)", "sonnet", "opus", "haiku",
|
||
};
|
||
|
||
public System.Collections.ObjectModel.ObservableCollection<AgentInfo> TaskAgentOptions { get; } = new();
|
||
|
||
private bool _suppressAgentSave;
|
||
private CancellationTokenSource? _agentSaveCts;
|
||
|
||
public bool IsAgentSectionEnabled => !IsRunning;
|
||
|
||
[ObservableProperty] private string? _worktreePath;
|
||
[ObservableProperty] private string? _worktreeBaseCommit;
|
||
[ObservableProperty] private string? _worktreeStateLabel;
|
||
[ObservableProperty] private string? _branchLine;
|
||
[ObservableProperty] private int _turns;
|
||
[ObservableProperty] private int _tokens;
|
||
[ObservableProperty] private int _diffAdditions;
|
||
[ObservableProperty] private int _diffDeletions;
|
||
[ObservableProperty] private int _commitsOnBranch;
|
||
|
||
public string TokensFormatted => Tokens >= 1000 ? $"{Tokens / 1000.0:F1}k" : Tokens.ToString();
|
||
public string ElapsedFormatted => ""; // placeholder — no start-time stored yet
|
||
|
||
partial void OnTokensChanged(int value) => OnPropertyChanged(nameof(TokensFormatted));
|
||
partial void OnDiffAdditionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
|
||
partial void OnDiffDeletionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
|
||
|
||
// 0.0–1.0 additions share for the diff meter
|
||
public double DiffMeterRatio
|
||
{
|
||
get
|
||
{
|
||
var total = DiffAdditions + DiffDeletions;
|
||
return total == 0 ? 0.0 : (double)DiffAdditions / total;
|
||
}
|
||
}
|
||
|
||
public ObservableCollection<LogLineViewModel> Log { get; } = new();
|
||
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
|
||
|
||
[ObservableProperty] private string _newSubtaskTitle = "";
|
||
|
||
// Planning merge controls
|
||
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
|
||
[ObservableProperty] private string? _selectedMergeTarget;
|
||
[ObservableProperty]
|
||
[NotifyCanExecuteChangedFor(nameof(MergeAllCommand))]
|
||
private bool _canMergeAll;
|
||
[ObservableProperty] private string? _mergeAllDisabledReason;
|
||
[ObservableProperty] private string? _mergeAllError;
|
||
|
||
// Claude CLI stream-json parser + buffer for partial text deltas
|
||
private readonly StreamLineFormatter _formatter = new();
|
||
private readonly StringBuilder _claudeBuf = new();
|
||
|
||
// The task ID we are currently subscribed to for live log messages
|
||
private string? _subscribedTaskId;
|
||
|
||
private CancellationTokenSource? _loadCts;
|
||
|
||
// Set by shell so CloseDetailCommand can clear SelectedTask
|
||
public Action? CloseDetail { get; set; }
|
||
|
||
// Set by shell so DeleteTaskCommand can remove from list
|
||
public Func<TaskRowViewModel, System.Threading.Tasks.Task>? DeleteFromList { get; set; }
|
||
|
||
// Set by the view so OpenDiffCommand can show the modal as a dialog
|
||
public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; }
|
||
|
||
// Set by the view so OpenWorktreeCommand can show the modal as a dialog
|
||
public Func<WorktreeModalViewModel, System.Threading.Tasks.Task>? ShowWorktreeModal { get; set; }
|
||
|
||
// Set by the view so ApproveMergeCommand can show the modal as a dialog
|
||
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
|
||
|
||
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
||
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
||
|
||
// Set by the view so ReviewCombinedDiffCommand can show the planning diff modal
|
||
public Func<ViewModels.Planning.PlanningDiffViewModel, System.Threading.Tasks.Task>? ShowPlanningDiffModal { get; set; }
|
||
|
||
// Set by the view so DeleteTaskCommand can show an error message
|
||
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
||
|
||
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, IServiceProvider services)
|
||
{
|
||
_dbFactory = dbFactory;
|
||
_worker = worker;
|
||
_services = services;
|
||
|
||
// Subscribe once; filter by current task id inside the handler
|
||
_worker.TaskMessageEvent += OnTaskMessage;
|
||
|
||
// Re-evaluate CanExecute when worker connection flips.
|
||
_worker.PropertyChanged += (_, e) =>
|
||
{
|
||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||
{
|
||
RunNowCommand.NotifyCanExecuteChanged();
|
||
ContinueCommand.NotifyCanExecuteChanged();
|
||
ResetCommand.NotifyCanExecuteChanged();
|
||
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||
}
|
||
};
|
||
|
||
// If the task row's live status changes (e.g. TaskStarted/Finished), mirror it.
|
||
_worker.TaskStartedEvent += (slot, taskId, startedAt) =>
|
||
{
|
||
if (Task?.Id == taskId) AgentStatusLabel = "Running";
|
||
};
|
||
_worker.TaskFinishedEvent += (slot, taskId, status, finishedAt) =>
|
||
{
|
||
if (Task?.Id != taskId) return;
|
||
FlushClaudeBuffer();
|
||
Log.Add(new LogLineViewModel
|
||
{
|
||
Kind = LogKind.Done,
|
||
Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──",
|
||
});
|
||
AgentStatusLabel = status;
|
||
// Re-query to pick up worktree created during the run.
|
||
_ = RefreshWorktreeAsync(taskId);
|
||
};
|
||
|
||
_worker.WorktreeUpdatedEvent += taskId =>
|
||
{
|
||
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
|
||
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||
};
|
||
|
||
_worker.TaskUpdatedEvent += taskId =>
|
||
{
|
||
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||
};
|
||
|
||
Subtasks.CollectionChanged += (_, _) =>
|
||
{
|
||
RecomputeCanMergeAll();
|
||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||
};
|
||
}
|
||
|
||
private void OnTaskMessage(string taskId, string line)
|
||
{
|
||
if (taskId != _subscribedTaskId) return;
|
||
|
||
// `[stdout] ...json...` lines are Claude CLI stream-json; parse through the
|
||
// formatter so the user sees human text, not raw JSON.
|
||
if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var body = line["[stdout]".Length..].TrimStart();
|
||
var formatted = _formatter.FormatLine(body);
|
||
if (formatted is null) return; // filter noise (message_start, etc.)
|
||
AppendClaudeText(formatted);
|
||
return;
|
||
}
|
||
|
||
// Non-stdout tagged lines: flush any buffered text then classify by prefix.
|
||
FlushClaudeBuffer();
|
||
|
||
var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys
|
||
: line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool
|
||
: line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude
|
||
: line.StartsWith("[stderr]", StringComparison.OrdinalIgnoreCase) ? LogKind.Stderr
|
||
: line.StartsWith("[done]", StringComparison.OrdinalIgnoreCase) ? LogKind.Done
|
||
: LogKind.Msg;
|
||
Log.Add(new LogLineViewModel { Kind = kind, Text = line });
|
||
}
|
||
|
||
private void AppendClaudeText(string chunk)
|
||
{
|
||
_claudeBuf.Append(chunk);
|
||
// Emit a log entry for every completed line; keep the trailing remainder buffered.
|
||
while (true)
|
||
{
|
||
var text = _claudeBuf.ToString();
|
||
var nl = text.IndexOf('\n');
|
||
if (nl < 0) break;
|
||
var piece = text[..nl].TrimEnd('\r');
|
||
if (!string.IsNullOrWhiteSpace(piece))
|
||
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||
_claudeBuf.Clear();
|
||
_claudeBuf.Append(text[(nl + 1)..]);
|
||
}
|
||
}
|
||
|
||
private void FlushClaudeBuffer()
|
||
{
|
||
if (_claudeBuf.Length == 0) return;
|
||
var piece = _claudeBuf.ToString().TrimEnd();
|
||
_claudeBuf.Clear();
|
||
if (!string.IsNullOrWhiteSpace(piece))
|
||
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||
}
|
||
|
||
partial void OnTaskModelSelectionChanged(string value) => QueueAgentSave();
|
||
partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave();
|
||
partial void OnTaskSelectedAgentChanged(AgentInfo? value) => QueueAgentSave();
|
||
|
||
private void QueueAgentSave()
|
||
{
|
||
if (_suppressAgentSave || Task is null) return;
|
||
_agentSaveCts?.Cancel();
|
||
_agentSaveCts = new CancellationTokenSource();
|
||
var ct = _agentSaveCts.Token;
|
||
_ = SaveAgentSettingsAsync(ct);
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task SaveAgentSettingsAsync(CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
await System.Threading.Tasks.Task.Delay(300, ct);
|
||
if (Task is null) return;
|
||
|
||
var model = TaskModelSelection == "(inherit)" ? null : TaskModelSelection;
|
||
var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
|
||
var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
|
||
? null : TaskSelectedAgent.Path;
|
||
|
||
await _worker.UpdateTaskAgentSettingsAsync(
|
||
new ClaudeDo.Ui.Services.UpdateTaskAgentSettingsDto(Task.Id, model, sp, ap));
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
catch { }
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task LoadAgentSettingsAsync(
|
||
ClaudeDo.Data.Models.TaskEntity entity, CancellationToken ct)
|
||
{
|
||
_suppressAgentSave = true;
|
||
try
|
||
{
|
||
TaskAgentOptions.Clear();
|
||
TaskAgentOptions.Add(new AgentInfo("(inherit)", "", ""));
|
||
var agents = await _worker.GetAgentsAsync();
|
||
foreach (var a in agents) TaskAgentOptions.Add(a);
|
||
|
||
TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? "(inherit)" : entity.Model!;
|
||
TaskSystemPrompt = entity.SystemPrompt ?? "";
|
||
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
|
||
? TaskAgentOptions[0]
|
||
: (TaskAgentOptions.FirstOrDefault(a => a.Path == entity.AgentPath) ?? TaskAgentOptions[0]);
|
||
|
||
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
|
||
EffectiveModelHint = string.IsNullOrWhiteSpace(listCfg?.Model) ? "(global default)" : listCfg!.Model!;
|
||
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt) ? "(none)" : listCfg!.SystemPrompt!;
|
||
EffectiveAgentHint = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
|
||
? "(none)" : System.IO.Path.GetFileName(listCfg!.AgentPath!);
|
||
}
|
||
finally
|
||
{
|
||
_suppressAgentSave = false;
|
||
}
|
||
}
|
||
|
||
public void Bind(TaskRowViewModel? row)
|
||
{
|
||
_loadCts?.Cancel();
|
||
_loadCts?.Dispose();
|
||
_loadCts = new CancellationTokenSource();
|
||
var ct = _loadCts.Token;
|
||
|
||
Task = row;
|
||
OnPropertyChanged(nameof(TaskIdBadge));
|
||
Log.Clear();
|
||
Subtasks.Clear();
|
||
MergeTargetBranches.Clear();
|
||
SelectedMergeTarget = null;
|
||
CanMergeAll = false;
|
||
MergeAllDisabledReason = null;
|
||
MergeAllError = null;
|
||
_claudeBuf.Clear();
|
||
|
||
if (row == null)
|
||
{
|
||
_subscribedTaskId = null;
|
||
EditableTitle = "";
|
||
Notes = "";
|
||
Model = null;
|
||
WorktreePath = null;
|
||
WorktreeStateLabel = null;
|
||
BranchLine = null;
|
||
AgentStatusLabel = "Idle";
|
||
LatestRunSessionId = null;
|
||
ShowFailedActions = false;
|
||
_suppressAgentSave = true;
|
||
try
|
||
{
|
||
TaskModelSelection = "(inherit)";
|
||
TaskSystemPrompt = "";
|
||
TaskSelectedAgent = null;
|
||
}
|
||
finally
|
||
{
|
||
_suppressAgentSave = false;
|
||
}
|
||
EffectiveModelHint = "";
|
||
EffectiveSystemPromptHint = "";
|
||
EffectiveAgentHint = "";
|
||
return;
|
||
}
|
||
|
||
_ = BindAsync(row, ct);
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||
var subtaskRepo = new SubtaskRepository(ctx);
|
||
|
||
// Own query with Include so WorktreePath/BranchLine are populated.
|
||
var entity = await ctx.Tasks
|
||
.AsNoTracking()
|
||
.Include(t => t.Worktree)
|
||
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
||
ct.ThrowIfCancellationRequested();
|
||
if (entity == null) return;
|
||
|
||
EditableTitle = entity.Title;
|
||
Notes = entity.Notes ?? "";
|
||
Model = entity.Model;
|
||
WorktreePath = entity.Worktree?.Path;
|
||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||
AgentStatusLabel = entity.Status.ToString();
|
||
await LoadAgentSettingsAsync(entity, ct);
|
||
ct.ThrowIfCancellationRequested();
|
||
|
||
var runRepo = new TaskRunRepository(ctx);
|
||
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
|
||
ct.ThrowIfCancellationRequested();
|
||
LatestRunSessionId = latestRun?.SessionId;
|
||
|
||
// Subscribe only after DB load confirms the task exists
|
||
_subscribedTaskId = row.Id;
|
||
|
||
// Replay the latest run's persisted log so output is visible across app restarts.
|
||
await ReplayLogFileAsync(entity.LogPath, ct);
|
||
|
||
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
|
||
ct.ThrowIfCancellationRequested();
|
||
foreach (var s in subs)
|
||
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
||
|
||
if (entity.Status == ClaudeDo.Data.Models.TaskStatus.Planning ||
|
||
entity.Status == ClaudeDo.Data.Models.TaskStatus.Planned)
|
||
{
|
||
await LoadPlanningChildrenAsync(row.Id, ct);
|
||
}
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task ReplayLogFileAsync(string? logPath, CancellationToken ct)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(logPath)) return;
|
||
var expanded = ExpandUserPath(logPath);
|
||
if (!System.IO.File.Exists(expanded)) return;
|
||
|
||
try
|
||
{
|
||
const int maxLines = 2000;
|
||
string[] all;
|
||
await using (var fs = new System.IO.FileStream(
|
||
expanded,
|
||
System.IO.FileMode.Open,
|
||
System.IO.FileAccess.Read,
|
||
System.IO.FileShare.ReadWrite | System.IO.FileShare.Delete))
|
||
using (var reader = new System.IO.StreamReader(fs))
|
||
{
|
||
var list = new List<string>();
|
||
while (await reader.ReadLineAsync(ct) is { } line)
|
||
list.Add(line);
|
||
all = list.ToArray();
|
||
}
|
||
ct.ThrowIfCancellationRequested();
|
||
|
||
var start = Math.Max(0, all.Length - maxLines);
|
||
for (int i = start; i < all.Length; i++)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
if (_subscribedTaskId is null) return;
|
||
// Worker writes raw Claude CLI stdout to disk (no prefix) but broadcasts
|
||
// it with a "[stdout] " prefix. Match the live-stream format so the same
|
||
// stream-json parser handles both.
|
||
var line = all[i];
|
||
var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line;
|
||
OnTaskMessage(_subscribedTaskId, normalized);
|
||
}
|
||
FlushClaudeBuffer();
|
||
}
|
||
catch (OperationCanceledException) { throw; }
|
||
catch { /* best-effort replay */ }
|
||
}
|
||
|
||
private static string ExpandUserPath(string path)
|
||
{
|
||
if (path.StartsWith("~/", StringComparison.Ordinal) || path.StartsWith("~\\", StringComparison.Ordinal))
|
||
return System.IO.Path.Combine(
|
||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||
path[2..]);
|
||
if (path == "~")
|
||
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||
return path;
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||
var children = await ctx.Tasks
|
||
.AsNoTracking()
|
||
.Include(t => t.Worktree)
|
||
.Where(t => t.ParentTaskId == parentTaskId)
|
||
.ToListAsync(ct);
|
||
ct.ThrowIfCancellationRequested();
|
||
|
||
foreach (var child in children)
|
||
{
|
||
var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
|
||
if (existing != null)
|
||
{
|
||
existing.Status = child.Status;
|
||
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||
}
|
||
}
|
||
|
||
if (MergeTargetBranches.Count == 0)
|
||
{
|
||
var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null);
|
||
if (childWithWorktree != null)
|
||
{
|
||
var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id);
|
||
if (targets != null)
|
||
{
|
||
MergeTargetBranches.Clear();
|
||
foreach (var b in targets.LocalBranches)
|
||
MergeTargetBranches.Add(b);
|
||
SelectedMergeTarget = targets.DefaultBranch;
|
||
}
|
||
}
|
||
}
|
||
|
||
RecomputeCanMergeAll();
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
catch { /* best-effort */ }
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId)
|
||
{
|
||
if (Task is null) return;
|
||
try
|
||
{
|
||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||
var child = await ctx.Tasks
|
||
.AsNoTracking()
|
||
.Include(t => t.Worktree)
|
||
.FirstOrDefaultAsync(t => t.Id == childTaskId && t.ParentTaskId == Task.Id);
|
||
if (child == null) return;
|
||
|
||
var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
|
||
if (existing != null)
|
||
{
|
||
existing.Status = child.Status;
|
||
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||
}
|
||
|
||
RecomputeCanMergeAll();
|
||
}
|
||
catch { /* best-effort */ }
|
||
}
|
||
|
||
internal void RecomputeCanMergeAll()
|
||
{
|
||
var notDone = Subtasks.Count(c => c.Status != ClaudeDo.Data.Models.TaskStatus.Done);
|
||
if (notDone > 0)
|
||
{
|
||
CanMergeAll = false;
|
||
MergeAllDisabledReason = $"{notDone} subtask(s) not done";
|
||
return;
|
||
}
|
||
var badWt = Subtasks.FirstOrDefault(c =>
|
||
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Discarded ||
|
||
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Kept);
|
||
if (badWt is not null)
|
||
{
|
||
CanMergeAll = false;
|
||
MergeAllDisabledReason = "at least one worktree was discarded/kept";
|
||
return;
|
||
}
|
||
CanMergeAll = true;
|
||
MergeAllDisabledReason = null;
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
|
||
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
|
||
{
|
||
if (Task is null || ShowPlanningDiffModal is null) return;
|
||
var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, Task.Id, SelectedMergeTarget ?? "main");
|
||
await vm.InitializeAsync();
|
||
await ShowPlanningDiffModal(vm);
|
||
}
|
||
|
||
private bool CanReviewDiff() => Task?.IsPlanningParent == true && Subtasks.Any();
|
||
|
||
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
||
private async System.Threading.Tasks.Task MergeAllAsync()
|
||
{
|
||
MergeAllError = null;
|
||
try
|
||
{
|
||
await _worker.MergeAllPlanningAsync(Task!.Id, SelectedMergeTarget ?? "main");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
MergeAllError = ex.Message;
|
||
}
|
||
}
|
||
|
||
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
|
||
{
|
||
try
|
||
{
|
||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||
var entity = await ctx.Tasks
|
||
.AsNoTracking()
|
||
.Include(t => t.Worktree)
|
||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||
if (entity == null || Task?.Id != taskId) return;
|
||
|
||
WorktreePath = entity.Worktree?.Path;
|
||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
||
AgentStatusLabel = entity.Status.ToString();
|
||
if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
|
||
row.DiffStat = stat;
|
||
}
|
||
catch { /* best-effort refresh */ }
|
||
}
|
||
|
||
[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>())
|
||
{
|
||
WorktreePath = WorktreePath,
|
||
BaseRef = WorktreeBaseCommit,
|
||
TaskId = Task?.Id,
|
||
TaskTitle = Task?.Title ?? "",
|
||
ShowMergeModal = ShowMergeModal,
|
||
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||
};
|
||
await diffVm.LoadAsync();
|
||
await ShowDiffModal(diffVm);
|
||
}
|
||
|
||
private bool CanOpenDiff() => WorktreePath != null;
|
||
|
||
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
|
||
private void OpenWorktree()
|
||
{
|
||
if (WorktreePath == null) return;
|
||
try
|
||
{
|
||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = WorktreePath,
|
||
UseShellExecute = true,
|
||
});
|
||
}
|
||
catch { /* explorer open is best-effort */ }
|
||
}
|
||
|
||
private bool CanOpenWorktree() => WorktreePath != null;
|
||
|
||
partial void OnWorktreePathChanged(string? value)
|
||
{
|
||
OpenDiffCommand.NotifyCanExecuteChanged();
|
||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||
}
|
||
|
||
partial void OnWorktreeStateLabelChanged(string? value)
|
||
{
|
||
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task SendPromptAsync()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(PromptInput) || Task == null) return;
|
||
Log.Add(new LogLineViewModel { Kind = LogKind.Msg, Text = $"[you] {PromptInput}" });
|
||
// TODO: WorkerClient has no SendPromptAsync — no matching hub method found.
|
||
// When the worker gains a "SendPrompt" hub method, call:
|
||
// await _worker.SendPromptAsync(Task.Id, PromptInput);
|
||
PromptInput = "";
|
||
await System.Threading.Tasks.Task.CompletedTask;
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void CloseDetails() => CloseDetail?.Invoke();
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task DeleteTaskAsync()
|
||
{
|
||
if (Task == null) return;
|
||
var row = Task;
|
||
if (ConfirmAsync != null)
|
||
{
|
||
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
|
||
if (!ok) return;
|
||
}
|
||
try
|
||
{
|
||
await using var ctx = _dbFactory.CreateDbContext();
|
||
var repo = new TaskRepository(ctx);
|
||
await repo.DeleteAsync(row.Id);
|
||
}
|
||
catch (DbUpdateException ex) when (
|
||
ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|
||
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)
|
||
{
|
||
if (ShowErrorAsync != null)
|
||
await ShowErrorAsync("This task has child tasks. Discard the planning session or delete child tasks first.");
|
||
return;
|
||
}
|
||
if (DeleteFromList != null)
|
||
await DeleteFromList(row);
|
||
CloseDetail?.Invoke();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task AddSubtaskAsync()
|
||
{
|
||
if (Task is null) return;
|
||
var title = NewSubtaskTitle?.Trim();
|
||
if (string.IsNullOrEmpty(title)) return;
|
||
|
||
var entity = new ClaudeDo.Data.Models.SubtaskEntity
|
||
{
|
||
Id = Guid.NewGuid().ToString(),
|
||
TaskId = Task.Id,
|
||
Title = title,
|
||
Completed = false,
|
||
OrderNum = Subtasks.Count,
|
||
CreatedAt = DateTime.UtcNow,
|
||
};
|
||
|
||
await using var ctx = _dbFactory.CreateDbContext();
|
||
await new SubtaskRepository(ctx).AddAsync(entity);
|
||
|
||
Subtasks.Add(new SubtaskRowViewModel { Id = entity.Id, Title = entity.Title, Done = entity.Completed });
|
||
NewSubtaskTitle = "";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task SaveNotesAsync()
|
||
{
|
||
if (Task == null) return;
|
||
await using var ctx = _dbFactory.CreateDbContext();
|
||
var repo = new TaskRepository(ctx);
|
||
var entity = await repo.GetByIdAsync(Task.Id);
|
||
if (entity == null) return;
|
||
entity.Notes = Notes;
|
||
await repo.UpdateAsync(entity);
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(CanMerge))]
|
||
private async System.Threading.Tasks.Task ApproveMergeAsync()
|
||
{
|
||
if (Task == null || ShowMergeModal == null) return;
|
||
var vm = _services.GetRequiredService<MergeModalViewModel>();
|
||
await vm.InitializeAsync(Task.Id, Task.Title);
|
||
await ShowMergeModal(vm);
|
||
}
|
||
|
||
private bool CanMerge() =>
|
||
Task != null && _worker.IsConnected && WorktreePath != null && WorktreeStateLabel == "Active";
|
||
|
||
[RelayCommand]
|
||
private async System.Threading.Tasks.Task StopAsync()
|
||
{
|
||
if (Task == null) return;
|
||
await _worker.CancelTaskAsync(Task.Id);
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(CanRunNow))]
|
||
private async System.Threading.Tasks.Task RunNowAsync()
|
||
{
|
||
if (Task == null) return;
|
||
AgentStatusLabel = "Running";
|
||
try
|
||
{
|
||
await _worker.RunNowAsync(Task.Id);
|
||
}
|
||
catch
|
||
{
|
||
AgentStatusLabel = "Failed";
|
||
throw;
|
||
}
|
||
}
|
||
|
||
private bool CanRunNow() =>
|
||
Task != null && _worker.IsConnected && !IsRunning;
|
||
|
||
[RelayCommand(CanExecute = nameof(CanContinue))]
|
||
private async System.Threading.Tasks.Task ContinueAsync()
|
||
{
|
||
if (Task == null) return;
|
||
await _worker.ContinueTaskAsync(Task.Id, "Continue working on this task.");
|
||
}
|
||
|
||
private bool CanContinue() =>
|
||
Task != null && _worker.IsConnected && ShowFailedActions && !string.IsNullOrEmpty(LatestRunSessionId);
|
||
|
||
[RelayCommand(CanExecute = nameof(CanReset))]
|
||
private async System.Threading.Tasks.Task ResetAsync()
|
||
{
|
||
if (Task == null) return;
|
||
if (ConfirmAsync == null) return;
|
||
|
||
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
||
var ok = await ConfirmAsync($"Discard worktree and reset task?\nThis deletes branch {branchName} and all uncommitted changes.");
|
||
if (!ok) return;
|
||
|
||
await _worker.ResetTaskAsync(Task.Id);
|
||
}
|
||
|
||
private bool CanReset() =>
|
||
Task != null && _worker.IsConnected && ShowFailedActions;
|
||
}
|
||
|
||
public sealed partial class SubtaskRowViewModel : ViewModelBase
|
||
{
|
||
public required string Id { get; init; }
|
||
[ObservableProperty] private string _title = "";
|
||
[ObservableProperty] private bool _done;
|
||
[ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status;
|
||
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active;
|
||
}
|