using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services.Interfaces; using ClaudeDo.Ui.ViewModels.Agent; using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using System.IO; namespace ClaudeDo.Ui.ViewModels.Islands; public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable { private readonly IDbContextFactory _dbFactory; private readonly IWorkerClient _worker; private readonly IServiceProvider _services; private readonly INotesApi _notesApi; private readonly IMergeCoordinator _merge; // ── Section view models ─────────────────────────────────────────────────── public AgentConfigEditorViewModel AgentSettings { get; } public MergeSectionViewModel Merge { get; } public PrepPanelViewModel Prep { get; } // Captured handler delegates for disposal private readonly EventHandler _langChangedHandler; private readonly System.ComponentModel.PropertyChangedEventHandler _workerPropertyChangedHandler; private readonly Action _workerTaskStartedHandler; private readonly Action _workerTaskFinishedHandler; private readonly Action _workerWorktreeUpdatedHandler; private readonly Action _workerTaskUpdatedHandler; [ObservableProperty] private bool _isNotesMode; [ObservableProperty] private bool _isPrepMode; public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode; partial void OnIsNotesModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible)); partial void OnIsPrepModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible)); public NotesEditorViewModel Notes { get; private set; } = null!; // Current task row (set by IslandsShellViewModel via Bind) [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(EnqueueCommand))] [NotifyCanExecuteChangedFor(nameof(DequeueCommand))] [NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))] [NotifyPropertyChangedFor(nameof(TaskIdBadge))] private TaskRowViewModel? _task; // Editable fields [ObservableProperty] [NotifyPropertyChangedFor(nameof(ComposedPreview))] private string _editableTitle = ""; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ComposedPreview))] private string _editableDescription = ""; [ObservableProperty] private bool _isEditingDescription; [ObservableProperty] private bool _isDescriptionExpanded = true; public bool IsDescriptionEditorVisible => IsDescriptionExpanded && IsEditingDescription; public bool IsDescriptionPreviewVisible => IsDescriptionExpanded && !IsEditingDescription; partial void OnIsDescriptionExpandedChanged(bool value) { OnPropertyChanged(nameof(IsDescriptionEditorVisible)); OnPropertyChanged(nameof(IsDescriptionPreviewVisible)); } partial void OnIsEditingDescriptionChanged(bool value) { OnPropertyChanged(nameof(IsDescriptionEditorVisible)); OnPropertyChanged(nameof(IsDescriptionPreviewVisible)); } [RelayCommand] private void ToggleEditDescription() => IsEditingDescription = !IsEditingDescription; [RelayCommand] private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded; // Which section of the details card is shown (header acts as a segment switcher). [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsDescriptionSection))] [NotifyPropertyChangedFor(nameof(IsStepsSection))] [NotifyPropertyChangedFor(nameof(IsFilesSection))] private string _detailSection = "description"; public bool IsDescriptionSection => DetailSection == "description"; public bool IsStepsSection => DetailSection == "steps"; public bool IsFilesSection => DetailSection == "files"; [RelayCommand] private void SelectDetailSection(string? section) => DetailSection = section ?? "description"; public int TotalStepCount => Subtasks.Count; public int DoneStepCount => Subtasks.Count(s => s.Done); public string StepsBadge => TotalStepCount > 0 ? $"{DoneStepCount}/{TotalStepCount}" : ""; public string FilesBadge => Attachments.Count > 0 ? Attachments.Count.ToString() : ""; private void NotifyStepsChanged() { OnPropertyChanged(nameof(TotalStepCount)); OnPropertyChanged(nameof(DoneStepCount)); OnPropertyChanged(nameof(StepsBadge)); OnPropertyChanged(nameof(ComposedPreview)); } public string ComposedPreview => ClaudeDo.Data.TaskPromptComposer.Compose( EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done)), Task is not null ? Attachments.Select(a => Path.Combine(new AttachmentStore().TaskDir(Task.Id), a.FileName)) : null); [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsOutputTab))] [NotifyPropertyChangedFor(nameof(IsGitTab))] [NotifyPropertyChangedFor(nameof(IsSessionTab))] private string _selectedTab = "output"; public bool IsOutputTab => SelectedTab == "output"; public bool IsGitTab => SelectedTab == "git"; public bool IsSessionTab => SelectedTab == "session"; [RelayCommand] private void SelectTab(string? tab) => SelectedTab = tab ?? "output"; private void NotifySessionSections() { OnPropertyChanged(nameof(HasChildOutcomes)); Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count); NotifyAttention(); if (!HasChildOutcomes && SelectedTab == "session") SelectedTab = "output"; } public string TurnsText => $"{Turns}/{AgentSettings.EffectiveMaxTurns}"; public string DiffAddText => $"+{DiffAdditions}"; public string DiffDelText => $"-{DiffDeletions}"; // ── Monitor forwarding ─────────────────────────────────────────────────── public TaskMonitorViewModel Monitor { get; } public ObservableCollection Log => Monitor.Log; public string AgentState { get => Monitor.AgentState; set => Monitor.AgentState = value; } public string AgentStatusLabel => Monitor.AgentStatusLabel; public bool IsIdle => Monitor.IsIdle; public bool IsQueued => Monitor.IsQueued; public bool IsRunning => Monitor.IsRunning; public bool IsWaitingForReview => Monitor.IsWaitingForReview; public bool IsWaitingForChildren => Monitor.IsWaitingForChildren; public bool IsDone => Monitor.IsDone; public bool IsFailed => Monitor.IsFailed; public bool IsCancelled => Monitor.IsCancelled; public bool ShowContinue => Monitor.ShowContinue; public bool ShowResetAndRetry => Monitor.ShowResetAndRetry; public bool ShowRoadblock => Monitor.ShowRoadblock; public string RoadblockMessage => Monitor.RoadblockMessage; public bool ShowSessionOutcome => Monitor.ShowSessionOutcome; public bool ShowRoadblockCard => Monitor.ShowRoadblockCard; public string? SessionOutcome { get => Monitor.SessionOutcome; set => Monitor.SessionOutcome = value; } public string? Roadblocks { get => Monitor.Roadblocks; set => Monitor.Roadblocks = value; } public string SessionLabel => "claude-session"; public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : ""; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ContinueCommand))] private string? _latestRunSessionId; [ObservableProperty] private string? _model; [ObservableProperty] private string? _worktreePath; [ObservableProperty] private string? _worktreeBaseCommit; [ObservableProperty] private string? _worktreeHeadCommit; [ObservableProperty] private string? _worktreeStateLabel; private string? _listWorkingDir; [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 => ""; partial void OnTokensChanged(int value) => OnPropertyChanged(nameof(TokensFormatted)); partial void OnTurnsChanged(int value) => OnPropertyChanged(nameof(TurnsText)); partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffAddText)); } partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffDelText)); } public double DiffMeterRatio { get { var total = DiffAdditions + DiffDeletions; return total == 0 ? 0.0 : (double)DiffAdditions / total; } } public ObservableCollection Subtasks { get; } = new(); public ObservableCollection ChildOutcomes { get; } = new(); public ObservableCollection Attachments { get; } = new(); [ObservableProperty] private bool _isDragOver; [ObservableProperty] private string? _dropStatus; public bool CanAcceptDrop => Task is not null && !Task.IsRunning; public bool HasChildOutcomes => ChildOutcomes.Count > 0; 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 = ""; private CancellationTokenSource? _loadCts; private bool _suppressDescSave; private CancellationTokenSource? _descSaveCts; // 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 DeleteTaskCommand can prompt yes/no before deleting public Func>? ConfirmAsync { get; set; } // Set by the view so DeleteTaskCommand can show an error message public Func? ShowErrorAsync { get; set; } // ── Review ────────────────────────────────────────────────────────────── [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasReviewFeedback))] [NotifyCanExecuteChangedFor(nameof(RejectReviewCommand))] private string _reviewFeedback = ""; public bool HasReviewFeedback => !string.IsNullOrWhiteSpace(ReviewFeedback); public DetailsIslandViewModel( IDbContextFactory dbFactory, IWorkerClient worker, IServiceProvider services, INotesApi notesApi, IMergeCoordinator merge) { _dbFactory = dbFactory; _worker = worker; _services = services; _notesApi = notesApi; _merge = merge; Monitor = new TaskMonitorViewModel(dbFactory, worker); Monitor.PropertyChanged += OnMonitorPropertyChanged; AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task); Merge = new MergeSectionViewModel(worker, services); Prep = new PrepPanelViewModel(worker); Notes = new NotesEditorViewModel(_notesApi); Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged(); Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count); Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge)); AgentSettings.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(AgentConfigEditorViewModel.EffectiveMaxTurns)) OnPropertyChanged(nameof(TurnsText)); }; _langChangedHandler = (_, _) => OnPropertyChanged(nameof(AgentStatusLabel)); Loc.LanguageChanged += _langChangedHandler; _workerPropertyChangedHandler = (_, e) => { if (e.PropertyName == nameof(IWorkerClient.IsConnected)) { EnqueueCommand.NotifyCanExecuteChanged(); DequeueCommand.NotifyCanExecuteChanged(); ResetAndRetryCommand.NotifyCanExecuteChanged(); ContinueCommand.NotifyCanExecuteChanged(); } }; _worker.PropertyChanged += _workerPropertyChangedHandler; _workerTaskStartedHandler = (slot, taskId, startedAt) => { _ = RefreshChildOutcomeAsync(taskId); }; _worker.TaskStartedEvent += _workerTaskStartedHandler; _workerTaskFinishedHandler = (slot, taskId, status, finishedAt) => { if (Task?.Id != taskId) return; _ = RefreshWorktreeAsync(taskId); _ = RefreshChildOutcomeAsync(taskId); }; _worker.TaskFinishedEvent += _workerTaskFinishedHandler; _workerWorktreeUpdatedHandler = taskId => { if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId); if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId); _ = RefreshChildOutcomeAsync(taskId); }; _worker.WorktreeUpdatedEvent += _workerWorktreeUpdatedHandler; _workerTaskUpdatedHandler = taskId => { if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId); _ = RefreshChildOutcomeAsync(taskId); }; _worker.TaskUpdatedEvent += _workerTaskUpdatedHandler; ChildOutcomes.CollectionChanged += (_, _) => { Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count); NotifySessionSections(); }; } public void Dispose() { Monitor.PropertyChanged -= OnMonitorPropertyChanged; Monitor.Dispose(); Loc.LanguageChanged -= _langChangedHandler; _worker.PropertyChanged -= _workerPropertyChangedHandler; _worker.TaskStartedEvent -= _workerTaskStartedHandler; _worker.TaskFinishedEvent -= _workerTaskFinishedHandler; _worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler; _worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler; AgentSettings.Dispose(); Prep.Dispose(); } private void OnMonitorPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { switch (e.PropertyName) { case nameof(TaskMonitorViewModel.AgentState): OnPropertyChanged(nameof(AgentState)); OnPropertyChanged(nameof(AgentStatusLabel)); OnPropertyChanged(nameof(IsIdle)); OnPropertyChanged(nameof(IsQueued)); OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsWaitingForReview)); OnPropertyChanged(nameof(IsWaitingForChildren)); OnPropertyChanged(nameof(IsDone)); OnPropertyChanged(nameof(IsFailed)); OnPropertyChanged(nameof(IsCancelled)); OnPropertyChanged(nameof(ShowContinue)); OnPropertyChanged(nameof(ShowResetAndRetry)); OnPropertyChanged(nameof(ShowRoadblock)); OnPropertyChanged(nameof(RoadblockMessage)); OnPropertyChanged(nameof(ShowSessionOutcome)); OnPropertyChanged(nameof(ShowRoadblockCard)); EnqueueCommand.NotifyCanExecuteChanged(); DequeueCommand.NotifyCanExecuteChanged(); ResetAndRetryCommand.NotifyCanExecuteChanged(); ContinueCommand.NotifyCanExecuteChanged(); AgentSettings.IsRunning = IsRunning; NotifySessionSections(); OnPropertyChanged(nameof(CanAcceptDrop)); break; case nameof(TaskMonitorViewModel.SessionOutcome): OnPropertyChanged(nameof(SessionOutcome)); OnPropertyChanged(nameof(ShowSessionOutcome)); break; case nameof(TaskMonitorViewModel.Roadblocks): OnPropertyChanged(nameof(Roadblocks)); OnPropertyChanged(nameof(ShowRoadblockCard)); break; } } partial void OnEditableDescriptionChanged(string value) { if (_suppressDescSave || Task is null) return; _descSaveCts?.Cancel(); _descSaveCts = new CancellationTokenSource(); _ = SaveDescriptionAsync(_descSaveCts.Token); } private async System.Threading.Tasks.Task SaveDescriptionAsync(CancellationToken ct) { try { await System.Threading.Tasks.Task.Delay(400, ct); if (Task is null) return; await using var ctx = _dbFactory.CreateDbContext(); var repo = new TaskRepository(ctx); var entity = await repo.GetByIdAsync(Task.Id); if (entity is null) return; entity.Description = string.IsNullOrWhiteSpace(EditableDescription) ? null : EditableDescription; await repo.UpdateAsync(entity); } catch (OperationCanceledException) { } catch { } } public void ShowNotes() { Bind(null); IsPrepMode = false; IsNotesMode = true; _ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today)); } public void ShowPrep() { Bind(null); IsNotesMode = false; IsPrepMode = true; _ = Prep.LoadLastPrepLogIfEmptyAsync(); } public void Bind(TaskRowViewModel? row) { IsNotesMode = false; IsPrepMode = false; _loadCts?.Cancel(); _loadCts?.Dispose(); _loadCts = new CancellationTokenSource(); var ct = _loadCts.Token; Task = row; OnPropertyChanged(nameof(TaskIdBadge)); Monitor.Reset(); Subtasks.Clear(); ChildOutcomes.Clear(); Attachments.Clear(); DropStatus = null; OnPropertyChanged(nameof(HasChildOutcomes)); Merge.Clear(); if (row == null) { EditableTitle = ""; EditableDescription = ""; Model = null; WorktreePath = null; WorktreeHeadCommit = null; _listWorkingDir = null; WorktreeStateLabel = null; BranchLine = null; DiffAdditions = 0; DiffDeletions = 0; LatestRunSessionId = null; AgentSettings.Clear(); 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); 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; EditableTitle = entity.Title; _suppressDescSave = true; 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} ← main" : null; var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat); DiffAdditions = add; DiffDeletions = del; Monitor.ApplyState(entity.Status); Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent); Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit, WorktreeStateLabel, _listWorkingDir); await AgentSettings.LoadForTaskAsync(entity, ct); ct.ThrowIfCancellationRequested(); var runRepo = new TaskRunRepository(ctx); var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct); ct.ThrowIfCancellationRequested(); LatestRunSessionId = latestRun?.SessionId; Monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown); Monitor.SetTaskId(row.Id); await Monitor.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 }); var attachmentRepo = new TaskAttachmentRepository(ctx); var attachments = await attachmentRepo.ListByTaskIdAsync(row.Id, ct); ct.ThrowIfCancellationRequested(); foreach (var a in attachments) Attachments.Add(new AttachmentRowViewModel { FileName = a.FileName, ByteSize = a.ByteSize }); if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None) await LoadPlanningChildrenAsync(row.Id, ct); await LoadChildOutcomesAsync(row.Id, ct); if (entity.Worktree != null && entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None && Merge.MergeTargetBranches.Count == 0) { var targets = await _worker.GetMergeTargetsAsync(row.Id); if (targets != null) { Merge.MergeTargetBranches.Clear(); foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b); Merge.SelectedMergeTarget = targets.DefaultBranch; } } await Merge.RefreshMergePreviewAsync(); } catch (OperationCanceledException) { } } private async System.Threading.Tasks.Task LoadChildOutcomesAsync(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) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(ct); ct.ThrowIfCancellationRequested(); if (children.Count == 0) return; ChildOutcomes.Clear(); foreach (var c in children) ChildOutcomes.Add(new ChildOutcomeRowViewModel { Id = c.Id, Title = c.Title, Status = c.Status, RoadblockCount = c.RoadblockCount, WorktreeState = c.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active, }); OnPropertyChanged(nameof(HasChildOutcomes)); if (Merge.MergeTargetBranches.Count == 0) { var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null); if (childWithWorktree != null) { var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id); if (targets != null) { Merge.MergeTargetBranches.Clear(); foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b); Merge.SelectedMergeTarget = targets.DefaultBranch; } } } } catch (OperationCanceledException) { } catch { /* best-effort */ } } 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 (Merge.MergeTargetBranches.Count == 0) { var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null); if (childWithWorktree != null) { var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id); if (targets != null) { Merge.MergeTargetBranches.Clear(); foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b); Merge.SelectedMergeTarget = targets.DefaultBranch; } } } } 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; } } catch { /* best-effort */ } } private async System.Threading.Tasks.Task RefreshChildOutcomeAsync(string childTaskId) { var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId); if (row 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); if (child is null) return; row.Status = child.Status; row.RoadblockCount = child.RoadblockCount; row.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active; Merge.ReviewCombinedDiffCommand.NotifyCanExecuteChanged(); NotifyAttention(); } catch { /* best-effort */ } } 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) .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; if (Task is { } row && entity.Worktree?.DiffStat is { } stat) row.DiffStat = stat; var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat); DiffAdditions = add; DiffDeletions = del; Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit, WorktreeStateLabel, _listWorkingDir); } catch { /* best-effort refresh */ } } partial void OnWorktreePathChanged(string? value) { Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit, WorktreeStateLabel, _listWorkingDir); NotifySessionSections(); } partial void OnWorktreeHeadCommitChanged(string? value) => Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit, WorktreeStateLabel, _listWorkingDir); partial void OnTaskChanged(TaskRowViewModel? value) { Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true); NotifySessionSections(); OnPropertyChanged(nameof(CanAcceptDrop)); } [RelayCommand] private void CloseDetails() => CloseDetail?.Invoke(); [RelayCommand] private async System.Threading.Tasks.Task ToggleStarAsync() { if (Task is null) return; Task.IsStarred = !Task.IsStarred; await using var ctx = _dbFactory.CreateDbContext(); var repo = new TaskRepository(ctx); var entity = await repo.GetByIdAsync(Task.Id); if (entity is null) return; entity.IsStarred = Task.IsStarred; await repo.UpdateAsync(entity); } [RelayCommand] private async System.Threading.Tasks.Task ToggleDoneAsync() { if (Task is null) return; Task.Done = !Task.Done; await using var ctx = _dbFactory.CreateDbContext(); var repo = new TaskRepository(ctx); var entity = await repo.GetByIdAsync(Task.Id); if (entity is null) return; entity.Status = Task.Done ? ClaudeDo.Data.Models.TaskStatus.Done : ClaudeDo.Data.Models.TaskStatus.Idle; Task.Status = entity.Status; Monitor.ApplyState(entity.Status); await repo.UpdateAsync(entity); } [RelayCommand] private async System.Threading.Tasks.Task ToggleSubtaskDoneAsync(SubtaskRowViewModel? row) { if (row is null) return; row.Done = !row.Done; NotifyStepsChanged(); await using var ctx = _dbFactory.CreateDbContext(); var repo = new SubtaskRepository(ctx); var subs = await repo.GetByTaskIdAsync(Task?.Id ?? ""); var entity = subs.FirstOrDefault(s => s.Id == row.Id); if (entity is null) return; entity.Completed = row.Done; await repo.UpdateAsync(entity); } [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 (Microsoft.EntityFrameworkCore.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 CommitSubtaskEditAsync(SubtaskRowViewModel? row) { if (row is null || !row.IsEditing) return; row.IsEditing = false; var title = row.Title?.Trim() ?? ""; await using var ctx = _dbFactory.CreateDbContext(); var repo = new SubtaskRepository(ctx); if (string.IsNullOrEmpty(title)) { await repo.DeleteAsync(row.Id); Subtasks.Remove(row); return; } var subs = await repo.GetByTaskIdAsync(Task?.Id ?? ""); var entity = subs.FirstOrDefault(s => s.Id == row.Id); if (entity is null) return; if (entity.Title != title) { entity.Title = title; await repo.UpdateAsync(entity); } row.Title = title; OnPropertyChanged(nameof(ComposedPreview)); } [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 StopAsync() { if (Task == null || !IsRunning) return; if (!_worker.IsConnected) return; try { await _worker.CancelTaskAsync(Task.Id); } catch { /* offline */ } } [RelayCommand(CanExecute = nameof(CanEnqueue))] private async System.Threading.Tasks.Task EnqueueAsync() { if (Task == null) return; try { await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued); AgentState = "queued"; } catch { /* offline */ } } private bool CanEnqueue() => Task != null && _worker.IsConnected && IsIdle && (!Task.IsChild || Task.ParentFinalized); [RelayCommand(CanExecute = nameof(CanDequeue))] private async System.Threading.Tasks.Task DequeueAsync() { if (Task == null) return; try { await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Idle); AgentState = "idle"; } catch { /* offline */ } } private bool CanDequeue() => Task != null && _worker.IsConnected && IsQueued; [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 && ShowContinue && !string.IsNullOrEmpty(LatestRunSessionId); [RelayCommand(CanExecute = nameof(CanResetAndRetry))] private async System.Threading.Tasks.Task ResetAndRetryAsync() { if (Task == null) return; if (ConfirmAsync == null) return; var branchName = $"claudedo/{Task.Id.Replace("-", "")}"; var ok = await ConfirmAsync( $"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning."); if (!ok) return; if (WorktreePath != null) await _worker.ResetTaskAsync(Task.Id); try { await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued); AgentState = "queued"; } catch { /* offline */ } } private bool CanResetAndRetry() => Task != null && _worker.IsConnected && ShowResetAndRetry; [RelayCommand] private async System.Threading.Tasks.Task ApproveReviewAsync() { if (Task is null || !_worker.IsConnected) return; try { var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0; var result = await _worker.ApproveReviewAsync(Task.Id, Merge.SelectedMergeTarget ?? ""); if (!hasChildren && result?.Status == "conflict") await _merge.ResolveConflictAsync(Task.Id, Merge.SelectedMergeTarget ?? ""); } catch (Exception ex) { if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message); } } [RelayCommand(CanExecute = nameof(HasReviewFeedback))] private async System.Threading.Tasks.Task RejectReviewAsync() { if (Task is null || !_worker.IsConnected) return; var feedback = ReviewFeedback; if (string.IsNullOrWhiteSpace(feedback)) return; try { await _worker.RejectReviewToQueueAsync(Task.Id, feedback); } catch { /* stale review action; broadcast reconciles */ return; } ReviewFeedback = ""; } // Park: set the task aside (back to Idle), keeping its worktree intact. [RelayCommand] private async System.Threading.Tasks.Task ParkReviewAsync() { if (Task is null || !_worker.IsConnected) return; try { await _worker.RejectReviewToIdleAsync(Task.Id); } catch { /* stale review action; broadcast reconciles */ } } [RelayCommand] private async System.Threading.Tasks.Task ResetReviewAsync() { if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return; var branchName = $"claudedo/{Task.Id.Replace("-", "")}"; var ok = await ConfirmAsync( $"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle."); if (!ok) return; try { await _worker.ResetTaskAsync(Task.Id); } catch { /* stale review action; broadcast reconciles */ } } [RelayCommand] private async System.Threading.Tasks.Task CancelReviewAsync() { if (Task is null || !_worker.IsConnected) return; try { await _worker.CancelReviewAsync(Task.Id); } catch { /* stale review action; broadcast reconciles */ } } private async System.Threading.Tasks.Task ReloadAttachmentsAsync() { if (Task is null) return; try { await using var ctx = await _dbFactory.CreateDbContextAsync(); var attachments = await new TaskAttachmentRepository(ctx).ListByTaskIdAsync(Task.Id); Attachments.Clear(); foreach (var a in attachments) Attachments.Add(new AttachmentRowViewModel { FileName = a.FileName, ByteSize = a.ByteSize }); OnPropertyChanged(nameof(ComposedPreview)); } catch { /* best-effort */ } } public async System.Threading.Tasks.Task AddFilesAsync(IReadOnlyList<(string FileName, Stream Content)> files) { DetailSection = "files"; if (Task is null || Task.IsRunning) { DropStatus = Loc.T("details.attachments.selectIdleTask"); return; } var store = new AttachmentStore(); var successes = new List(); var failures = new List(); foreach (var (fileName, content) in files) { try { var byteSize = await store.SaveAsync(Task.Id, fileName, content); await using var ctx = await _dbFactory.CreateDbContextAsync(); var repo = new TaskAttachmentRepository(ctx); var existing = await repo.GetAsync(Task.Id, fileName); if (existing is not null) { existing.ByteSize = byteSize; await repo.UpdateAsync(existing); } else { await repo.AddAsync(new ClaudeDo.Data.Models.TaskAttachmentEntity { Id = Guid.NewGuid().ToString(), TaskId = Task.Id, FileName = fileName, ByteSize = byteSize, CreatedAt = DateTime.UtcNow, }); } successes.Add(fileName); } catch (InvalidOperationException ex) { failures.Add(string.Format(Loc.T("details.attachments.overLimitError"), fileName, ex.Message)); } catch (ArgumentException ex) { failures.Add(string.Format(Loc.T("details.attachments.invalidNameError"), fileName, ex.Message)); } catch (Exception ex) { failures.Add($"{fileName}: {ex.Message}"); } } await ReloadAttachmentsAsync(); if (failures.Count == 0) { var names = string.Join(", ", successes); DropStatus = string.Format(Loc.T("details.attachments.addedSummary"), names, successes.Count); } else if (successes.Count == 0) { DropStatus = string.Join(" · ", failures); } else { var names = string.Join(", ", successes); var addedPart = string.Format(Loc.T("details.attachments.addedSummary"), names, successes.Count); DropStatus = addedPart + " · " + string.Join(" · ", failures); } } [RelayCommand] private async System.Threading.Tasks.Task RemoveAttachment(AttachmentRowViewModel? row) { if (row is null || Task is null) return; try { new AttachmentStore().DeleteFile(Task.Id, row.FileName); await using var ctx = await _dbFactory.CreateDbContextAsync(); await new TaskAttachmentRepository(ctx).DeleteAsync(Task.Id, row.FileName); await ReloadAttachmentsAsync(); } catch { /* best-effort */ } } internal static (int Additions, int Deletions) ParseDiffStat(string? stat) { if (string.IsNullOrEmpty(stat)) return (0, 0); int add = 0, del = 0; var m1 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+insertion"); var m2 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+deletion"); if (m1.Success) int.TryParse(m1.Groups[1].Value, out add); if (m2.Success) int.TryParse(m2.Groups[1].Value, out del); return (add, del); } } public sealed class AttachmentRowViewModel { public required string FileName { get; init; } public required long ByteSize { get; init; } public string SizeText => ByteSize switch { >= 1024 * 1024 => $"{ByteSize / (1024.0 * 1024.0):F1} MB", >= 1024 => $"{ByteSize / 1024.0:F1} KB", _ => $"{ByteSize} B", }; } public sealed partial class SubtaskRowViewModel : ViewModelBase { public required string Id { get; init; } [ObservableProperty] private string _title = ""; [ObservableProperty] private bool _done; [ObservableProperty] private bool _isEditing; [ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status; [ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active; } // A suggested child's outcome on an improvement parent's review card. Observable so the // row reflects the child's live status (Idle → Running → Done/Failed) as it executes. public sealed partial class ChildOutcomeRowViewModel : ViewModelBase { public required string Id { get; init; } public required string Title { get; init; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(StatusLabel))] private ClaudeDo.Data.Models.TaskStatus _status; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasRoadblock))] [NotifyPropertyChangedFor(nameof(RoadblockText))] private int _roadblockCount; [ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active; public string StatusLabel => Status switch { ClaudeDo.Data.Models.TaskStatus.Done => Loc.T("vm.taskStatus.done"), ClaudeDo.Data.Models.TaskStatus.Failed => Loc.T("vm.taskStatus.failed"), ClaudeDo.Data.Models.TaskStatus.Cancelled => Loc.T("vm.taskStatus.cancelled"), ClaudeDo.Data.Models.TaskStatus.Running => Loc.T("vm.taskStatus.running"), ClaudeDo.Data.Models.TaskStatus.Queued => Loc.T("vm.taskStatus.queued"), ClaudeDo.Data.Models.TaskStatus.WaitingForReview => Loc.T("vm.taskStatus.waitingForReview"), ClaudeDo.Data.Models.TaskStatus.WaitingForChildren => Loc.T("vm.taskStatus.waitingForChildren"), _ => Loc.T("vm.taskStatus.idle"), }; public bool HasRoadblock => RoadblockCount > 0; public string RoadblockText => RoadblockCount == 1 ? "1 roadblock" : $"{RoadblockCount} roadblocks"; }