Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
mika kuns a6ebff3f34 feat(ui): add aggregated diff viewer for planning tasks
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>
2026-04-24 16:39:38 +02:00

821 lines
31 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.01.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;
}