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 _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 TaskModelOptions { get; } = new() { "(inherit)", "sonnet", "opus", "haiku", }; public System.Collections.ObjectModel.ObservableCollection 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 Log { get; } = new(); public ObservableCollection Subtasks { get; } = new(); [ObservableProperty] private string _newSubtaskTitle = ""; // Planning merge controls [ObservableProperty] private ObservableCollection _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? DeleteFromList { get; set; } // Set by the view so OpenDiffCommand can show the modal as a dialog public Func? ShowDiffModal { get; set; } // Set by the view so OpenWorktreeCommand can show the modal as a dialog public Func? ShowWorktreeModal { get; set; } // Set by the view so ApproveMergeCommand can show the modal as a dialog public Func? ShowMergeModal { get; set; } // Set by the view so DeleteTaskCommand can prompt yes/no before deleting public Func>? ConfirmAsync { get; set; } // Set by the view so ReviewCombinedDiffCommand can show the planning diff modal public Func? ShowPlanningDiffModal { get; set; } // Set by the view so DeleteTaskCommand can show an error message public Func? ShowErrorAsync { get; set; } public DetailsIslandViewModel(IDbContextFactory 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(); 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()) { WorktreePath = WorktreePath, BaseRef = WorktreeBaseCommit, TaskId = Task?.Id, TaskTitle = Task?.Title ?? "", ShowMergeModal = ShowMergeModal, ResolveMergeVm = () => _services.GetRequiredService(), }; 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(); 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; }