using System.Collections.ObjectModel; using System.Globalization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; using ClaudeDo.Data.Filtering; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.ViewModels.Islands; public sealed partial class TasksIslandViewModel : ViewModelBase { private readonly IDbContextFactory _dbFactory; private readonly IWorkerClient? _worker; private readonly Dictionary _expandedState = new(); private ListNavItemViewModel? _currentList; private CancellationTokenSource? _loadCts; private static readonly TaskListFilterRegistry _filters = new(); public event EventHandler? SelectionChanged; public event EventHandler? FocusAddTaskRequested; public event EventHandler? TasksChanged; public event Action? NotesRequested; public event Action? PrepRequested; public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty); [RelayCommand] private void OpenNotes() { SelectedTask = null; NotesRequested?.Invoke(); } [RelayCommand] private async Task PrepareDayAsync() { if (_worker is null) return; PrepRequested?.Invoke(); try { await _worker.RunDailyPrepNowAsync(); } catch { /* worker offline; broadcast will reconcile on return */ } } [RelayCommand] private void ShowPrepLog() => PrepRequested?.Invoke(); [RelayCommand] private async Task ClearDayAsync() { if (_worker is null) return; try { await _worker.ClearMyDayAsync(); } catch { /* worker offline; broadcast will reconcile on return */ } } public ObservableCollection Items { get; } = new(); public ObservableCollection OverdueItems { get; } = new(); public ObservableCollection OpenItems { get; } = new(); public ObservableCollection CompletedItems { get; } = new(); [ObservableProperty] private string _newTaskTitle = ""; [ObservableProperty] private TaskRowViewModel? _selectedTask; [ObservableProperty] private string _headerTitle = ""; [ObservableProperty] private string _headerEyebrow = ""; [ObservableProperty] private string _subtitle = ""; [ObservableProperty] private string _statusPill = ""; [ObservableProperty] private bool _hasStatusPill; [ObservableProperty] private bool _isShowingCompleted = true; [ObservableProperty] private bool _hasOverdue; [ObservableProperty] private bool _hasOpen; [ObservableProperty] private bool _hasCompleted; [ObservableProperty] private bool _showOpenLabel; [ObservableProperty] private string _completedHeader = ""; [ObservableProperty] private bool _showNotesRow; [ObservableProperty] private bool _isMyDayList; public Func? ShowUnfinishedPlanningModal { get; set; } public TasksIslandViewModel(IDbContextFactory dbFactory, IWorkerClient? worker = null) { _dbFactory = dbFactory; _worker = worker; CompletedHeader = Loc.T("vm.tasksIsland.completedHeader"); if (_worker is not null) { _worker.TaskUpdatedEvent += OnWorkerTaskUpdated; _worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated; _worker.ListUpdatedEvent += OnWorkerListUpdated; _worker.ConnectionRestoredEvent += () => LoadForList(_currentList); } Loc.LanguageChanged += (_, _) => RefreshLocalizedText(); } private void RefreshLocalizedText() { CompletedHeader = Loc.T("vm.tasksIsland.completedHeader"); foreach (var row in Items) row.RefreshLocalized(); foreach (var row in CompletedItems) row.RefreshLocalized(); } private async void OnWorkerListUpdated(string listId) { // Mirror the renamed list onto every task row that references it, // so the per-row ListName chip on virtual lists stays current. try { await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId); if (entity is null) return; var visibleIds = Items.Select(r => r.Id).ToHashSet(); if (visibleIds.Count == 0) return; var matchingIds = await db.Tasks.AsNoTracking() .Where(t => t.ListId == listId && visibleIds.Contains(t.Id)) .Select(t => t.Id) .ToListAsync(); var matching = matchingIds.ToHashSet(); foreach (var row in Items) if (matching.Contains(row.Id) && row.ListName != entity.Name) row.ListName = entity.Name; } catch { } } private async void OnWorkerTaskUpdated(string taskId) { var list = _currentList; if (list is null) return; // virtual:queued / virtual:running include Planning parents whose children match, // which can't be decided from a single entity. Always full-reload in those cases. if (list.Kind == ListKind.Virtual && (list.Id == "virtual:queued" || list.Id == "virtual:running")) { LoadForList(list); return; } try { await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks .Include(t => t.List) .Include(t => t.Worktree) .FirstOrDefaultAsync(t => t.Id == taskId); var existing = Items.FirstOrDefault(r => r.Id == taskId); if (entity is null) { if (existing is not null) Items.Remove(existing); } else { var matches = TaskMatchesList(entity, list); if (existing is not null && matches) existing.UpdateFromEntity(entity); else if (existing is not null) Items.Remove(existing); else if (matches) { LoadForList(list); return; } else return; } // Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips. if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId)) { var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId); if (parent is not null) parent.HasQueuedSubtasks = Items.Any(r => r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting)); } Regroup(); UpdateSubtitle(); } catch { } } // NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning // parent matches iff any child has the matching status. OnWorkerTaskUpdated handles those // lists via a full reload rather than the delta path. private static bool TaskMatchesList(TaskEntity t, ListNavItemViewModel list) => list.Kind switch { ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay, ListKind.Smart when list.Id == "smart:important" => t.IsStarred, ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null, ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null, ListKind.User => $"user:{t.ListId}" == list.Id, _ => false, }; private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (e.PropertyName == nameof(ListNavItemViewModel.Name) && sender is ListNavItemViewModel vm) HeaderTitle = vm.Name; } public void LoadForList(ListNavItemViewModel? list) { _loadCts?.Cancel(); _loadCts?.Dispose(); _loadCts = new CancellationTokenSource(); var ct = _loadCts.Token; if (_currentList is not null) _currentList.PropertyChanged -= OnCurrentListPropertyChanged; _currentList = list; if (_currentList is not null) _currentList.PropertyChanged += OnCurrentListPropertyChanged; Items.Clear(); OverdueItems.Clear(); OpenItems.Clear(); CompletedItems.Clear(); HasOverdue = false; HasOpen = false; HasCompleted = false; ShowOpenLabel = false; ShowNotesRow = false; if (list is null) return; HeaderTitle = list.Name; HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant(); ShowNotesRow = list.Id == "smart:my-day"; IsMyDayList = list.Id == "smart:my-day"; _ = LoadForListAsync(list, ct); } private async Task LoadForListAsync(ListNavItemViewModel list, CancellationToken ct) { try { await using var db = await _dbFactory.CreateDbContextAsync(ct); var all = await db.Tasks .Include(t => t.List) .Include(t => t.Worktree) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(ct); ct.ThrowIfCancellationRequested(); var filter = _filters.Resolve(list.Id); var filteredList = filter is null ? new List() : all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList(); var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet(); var existingIds = filteredList.Select(t => t.Id).ToHashSet(); foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!))) { if (existingIds.Add(c.Id)) filteredList.Add(c); } var showListChip = list.Kind == ListKind.Virtual; foreach (var t in filteredList) { var row = TaskRowViewModel.FromEntity(t); row.ShowListChip = showListChip; Items.Add(row); } // Mark any top-level row that has at least one child as a planning parent, // so its subtasks remain expandable even after the parent is queued/running. var parentsWithChildren = Items .Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId)) .Select(r => r.ParentTaskId!) .ToHashSet(); foreach (var r in Items) if (parentsWithChildren.Contains(r.Id)) r.HasPlanningChildren = true; // Mark planning parents whose children are currently queued/waiting, // so the dequeue affordance is visible on the parent row. var parentsWithQueuedKids = Items .Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId) && (r.IsQueued || r.IsWaiting)) .Select(r => r.ParentTaskId!) .ToHashSet(); foreach (var r in Items) r.HasQueuedSubtasks = parentsWithQueuedKids.Contains(r.Id); // A subtask is "Planned" (queueable) once its planning parent is finalized; // until then it is a "Draft". var finalizedParents = Items .Where(r => r.PlanningPhase == PlanningPhase.Finalized) .Select(r => r.Id) .ToHashSet(); foreach (var r in Items) r.ParentFinalized = !string.IsNullOrEmpty(r.ParentTaskId) && finalizedParents.Contains(r.ParentTaskId!); Regroup(); UpdateSubtitle(); } catch (OperationCanceledException) { } } internal void Regroup() { OverdueItems.Clear(); OpenItems.Clear(); CompletedItems.Clear(); // Auto-collapse planning parents whose every child is Done (unless the user // has explicitly toggled the row — saved state wins). var childrenByParent = Items .Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId)) .GroupBy(r => r.ParentTaskId!) .ToDictionary(g => g.Key, g => g.ToList()); foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild && r.PlanningPhase == PlanningPhase.Finalized && !r.Done)) { if (_expandedState.ContainsKey(parent.Id)) continue; if (childrenByParent.TryGetValue(parent.Id, out var kids) && kids.Count > 0 && kids.All(c => c.Status == TaskStatus.Done)) { parent.IsExpanded = false; } } // Restore IsExpanded from saved state foreach (var r in Items) { if (_expandedState.TryGetValue(r.Id, out var saved)) r.IsExpanded = saved; } // Build hierarchy-aware flat list: top-level rows interleaved with visible children. // Items is already ordered by SortOrder from the DB query. // Treat rows whose ParentTaskId is not in the current view as orphans -> top-level. var visibleIds = Items.Select(r => r.Id).ToHashSet(); bool IsTopLevel(TaskRowViewModel r) => !r.IsChild || string.IsNullOrEmpty(r.ParentTaskId) || !visibleIds.Contains(r.ParentTaskId!); var topLevel = Items.Where(IsTopLevel); var flat = new List(); var emitted = new HashSet(); foreach (var parent in topLevel) { if (!emitted.Add(parent.Id)) continue; flat.Add(parent); // Also expand for Done parents so their (Done) children reach the classification // loop and land in CompletedItems alongside the parent. if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded) { var children = Items.Where(r => r.ParentTaskId == parent.Id); foreach (var c in children) if (emitted.Add(c.Id)) flat.Add(c); } } var today = DateTime.Today; foreach (var r in flat) { var underOpenPlanningParent = r.IsChild && flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done); if (r.Done && !underOpenPlanningParent) CompletedItems.Add(r); else if (r.ScheduledFor is { } d && d.Date < today) OverdueItems.Add(r); else OpenItems.Add(r); } HasOverdue = OverdueItems.Count > 0; HasOpen = OpenItems.Count > 0; HasCompleted = CompletedItems.Count > 0; ShowOpenLabel = HasOpen && HasOverdue; CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", CompletedItems.Count); } private void UpdateSubtitle() { var now = DateTime.Now; var open = Items.Count(i => !i.Done); var running = Items.Count(i => i.Status == TaskStatus.Running); var review = Items.Count(i => i.Status == TaskStatus.Done && i.Branch != null); Subtitle = open == 1 ? "1 open task" : $"{open} open tasks"; if (running > 0 || review > 0) { StatusPill = $"{running} running · {review} review"; HasStatusPill = true; } else { StatusPill = ""; HasStatusPill = false; } } [RelayCommand] private async Task AddAsync() { if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return; var listId = _currentList.Id["user:".Length..]; await using var db = await _dbFactory.CreateDbContextAsync(); var maxSort = await db.Tasks .Where(t => t.ListId == listId) .Select(t => (int?)t.SortOrder) .MaxAsync(); var entity = new TaskEntity { Id = Guid.NewGuid().ToString("N"), ListId = listId, Title = NewTaskTitle.Trim(), CreatedAt = DateTime.UtcNow, SortOrder = (maxSort ?? -1) + 1, }; db.Tasks.Add(entity); await db.SaveChangesAsync(); var row = TaskRowViewModel.FromEntity(entity); row.ShowListChip = _currentList?.Kind == ListKind.Virtual; Items.Add(row); Regroup(); NewTaskTitle = ""; UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } public bool CanReorder => _currentList?.Kind == ListKind.User; public void ClearDropHints() { foreach (var r in Items) { r.DropHintAbove = false; r.DropHintBelow = false; } } public void SetDropHint(TaskRowViewModel target, bool placeBelow) { foreach (var r in Items) { var isTarget = ReferenceEquals(r, target); r.DropHintAbove = isTarget && !placeBelow; r.DropHintBelow = isTarget && placeBelow; } } public async Task ReorderAsync(TaskRowViewModel source, TaskRowViewModel target, bool placeBelow) { if (!CanReorder || _currentList is null) return; if (source.IsRunning || target.IsRunning) return; if (ReferenceEquals(source, target)) return; // Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild. MoveWithinCollection(Items, source, target, placeBelow); // Apply the same move in whichever section the row lives in. // Reorder never changes which section (Open/Overdue/Completed) a row belongs to — // that's determined by Done flag and ScheduledFor date, not drag-drop. var sourceSection = SectionFor(source); var targetSection = SectionFor(target); if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection)) MoveWithinCollection(sourceSection, source, target, placeBelow); var listId = _currentList.Id["user:".Length..]; var orderedIds = Items.Select(i => i.Id).ToList(); await using var db = await _dbFactory.CreateDbContextAsync(); var idSet = orderedIds.ToHashSet(); var entities = await db.Tasks .Where(t => t.ListId == listId && idSet.Contains(t.Id)) .ToListAsync(); for (int i = 0; i < orderedIds.Count; i++) { var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]); if (e is not null) e.SortOrder = i; } await db.SaveChangesAsync(); } private static void MoveWithinCollection( System.Collections.ObjectModel.ObservableCollection coll, TaskRowViewModel source, TaskRowViewModel target, bool placeBelow) { var srcIdx = coll.IndexOf(source); var tgtIdx = coll.IndexOf(target); if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return; var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx; if (srcIdx < finalIdx) finalIdx--; if (finalIdx < 0) finalIdx = 0; if (finalIdx >= coll.Count) finalIdx = coll.Count - 1; if (finalIdx == srcIdx) return; coll.Move(srcIdx, finalIdx); } private System.Collections.ObjectModel.ObservableCollection? SectionFor(TaskRowViewModel row) { if (OverdueItems.Contains(row)) return OverdueItems; if (OpenItems.Contains(row)) return OpenItems; if (CompletedItems.Contains(row)) return CompletedItems; return null; } [RelayCommand] private async Task ToggleDoneAsync(TaskRowViewModel row) { row.Done = !row.Done; await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity != null) { entity.Status = row.Done ? TaskStatus.Done : TaskStatus.Idle; row.Status = entity.Status; await db.SaveChangesAsync(); } Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } [RelayCommand] private async Task ClearCompletedAsync() { if (CompletedItems.Count == 0) return; // Delete children before parents so the parent-child FK (Restrict) doesn't // block removing a completed planning parent together with its done children. var toDelete = CompletedItems.OrderByDescending(r => r.IsChild).ToList(); if (ConfirmAsync is not null) { var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone."); if (!ok) return; } await using var db = await _dbFactory.CreateDbContextAsync(); var repo = new TaskRepository(db); foreach (var row in toDelete) { try { await repo.DeleteAsync(row.Id); Items.Remove(row); } catch { /* still referenced by open child tasks; leave it visible */ } } Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } [RelayCommand] private async Task ToggleStarAsync(TaskRowViewModel row) { row.IsStarred = !row.IsStarred; await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity != null) { entity.IsStarred = row.IsStarred; await db.SaveChangesAsync(); } TasksChanged?.Invoke(this, EventArgs.Empty); } public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status) { if (_worker is null) return; try { await _worker.SetTaskStatusAsync(row.Id, status); } catch { /* offline; broadcast won't fire */ } } [RelayCommand] private async Task SendToQueueAsync(TaskRowViewModel? row) { if (row is null || row.IsRunning) return; await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity is null) return; entity.Status = TaskStatus.Queued; await db.SaveChangesAsync(); row.Status = TaskStatus.Queued; if (_worker is not null) { try { await _worker.WakeQueueAsync(); } catch { } } Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } [RelayCommand] private async Task RemoveFromQueueAsync(TaskRowViewModel? row) { if (row is null) return; await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity is null) return; // Cascade to queued children when present — covers both planning parents // (PlanningPhase != None) and bare parents that have a manually-queued // chain. The X button's visibility is gated by the same condition // (HasQueuedSubtasks), so the handler matches what the user can see. var queuedChildren = await db.Tasks .Where(t => t.ParentTaskId == row.Id && t.Status == TaskStatus.Queued) .ToListAsync(); foreach (var c in queuedChildren) { c.Status = TaskStatus.Idle; c.BlockedByTaskId = null; } if (entity.Status == TaskStatus.Queued) entity.Status = TaskStatus.Idle; await db.SaveChangesAsync(); foreach (var c in queuedChildren) { var childRow = Items.FirstOrDefault(r => r.Id == c.Id); if (childRow is not null) { childRow.Status = TaskStatus.Idle; childRow.BlockedByTaskId = null; } } if (row.Status == TaskStatus.Queued) row.Status = TaskStatus.Idle; row.HasQueuedSubtasks = false; Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } [RelayCommand] private async Task CancelRunningTaskAsync(TaskRowViewModel? row) { if (row is null || !row.IsRunning || _worker is null) return; try { await _worker.CancelTaskAsync(row.Id); } catch { /* worker offline; the broadcast will reconcile when it returns */ } } // ── Review actions (visible when a task is WaitingForReview) ───────────── // Each delegates to the worker hub, which performs the transition and // broadcasts TaskUpdated; the row refreshes from that broadcast. [RelayCommand] private async Task ApproveReviewAsync(TaskRowViewModel? row) { if (row is null || !row.IsWaitingForReview || _worker is null) return; try { await _worker.ApproveReviewAsync(row.Id); } catch { /* offline; broadcast reconciles on return */ } } public async Task RejectReviewToQueueAsync(TaskRowViewModel row, string feedback) { if (!row.IsWaitingForReview || _worker is null) return; if (string.IsNullOrWhiteSpace(feedback)) return; try { await _worker.RejectReviewToQueueAsync(row.Id, feedback); } catch { /* offline; broadcast reconciles on return */ } } [RelayCommand] private async Task RejectReviewToIdleAsync(TaskRowViewModel? row) { if (row is null || !row.IsWaitingForReview || _worker is null) return; try { await _worker.RejectReviewToIdleAsync(row.Id); } catch { /* offline; broadcast reconciles on return */ } } [RelayCommand] private async Task CancelReviewAsync(TaskRowViewModel? row) { if (row is null || !row.IsWaitingForReview || _worker is null) return; try { await _worker.CancelReviewAsync(row.Id); } catch { /* offline; broadcast reconciles on return */ } } public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when) { if (row is null) return; await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity is null) return; entity.ScheduledFor = when; await db.SaveChangesAsync(); row.ScheduledFor = when; Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } [RelayCommand] private Task ClearScheduleAsync(TaskRowViewModel? row) => row is null ? Task.CompletedTask : SetScheduledForAsync(row, null); [RelayCommand] private void Select(TaskRowViewModel row) => SelectedTask = row; [RelayCommand] private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted; [RelayCommand] private void Sort() { /* placeholder — UI-only */ } public event EventHandler? OpenListSettingsRequested; [RelayCommand] private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty); [RelayCommand] private async Task OpenPlanningSessionAsync(TaskRowViewModel? row) { if (row is null) return; if (row.Status != TaskStatus.Idle || row.PlanningPhase != PlanningPhase.None) return; ForegroundHelper.AllowAny(); try { await _worker!.StartPlanningSessionAsync(row.Id); } catch { } } [RelayCommand] private async Task RunInteractivelyAsync(TaskRowViewModel? row) { if (row is null || _worker is null) return; ForegroundHelper.AllowAny(); try { await _worker.OpenInteractiveTerminalAsync(row.Id); } catch { } } [RelayCommand] private async Task ResumePlanningSessionAsync(TaskRowViewModel? row) { if (row is null || !row.IsPlanningParent) return; if (_worker is null) return; try { var draftCount = await _worker.GetPendingDraftCountAsync(row.Id); var modalVm = new UnfinishedPlanningModalViewModel { TaskTitle = row.Title, DraftCount = draftCount, }; if (ShowUnfinishedPlanningModal is null) return; await ShowUnfinishedPlanningModal(modalVm); var choice = await modalVm.Result.Task; switch (choice) { case UnfinishedPlanningModalResult.Resume: ForegroundHelper.AllowAny(); await _worker.ResumePlanningSessionAsync(row.Id); break; case UnfinishedPlanningModalResult.FinalizeNow: await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); break; case UnfinishedPlanningModalResult.Discard: await TryDiscardPlanningWithRetryAsync(row.Id); break; case UnfinishedPlanningModalResult.Cancel: default: break; } } catch { } } [RelayCommand] private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row) { if (row is null || _worker is null) return; await TryDiscardPlanningWithRetryAsync(row.Id); } /// /// Calls discard, and if it is blocked because children are queued, prompts the /// user to dequeue them and retries. Running children are surfaced as a hard /// block — the user must cancel them first. /// private async Task TryDiscardPlanningWithRetryAsync(string taskId) { if (_worker is null) return; DiscardPlanningOutcome outcome; try { outcome = await _worker.DiscardPlanningSessionAsync(taskId); } catch { return; } if (outcome.Result == DiscardPlanningResult.BlockedByQueuedChildren) { if (ConfirmAsync is null) return; var ok = await ConfirmAsync( $"{outcome.QueuedChildrenCount} child task(s) are queued.\n" + "Dequeue them and discard the planning session?"); if (!ok) return; try { await _worker.DiscardPlanningSessionAsync(taskId, dequeueQueuedChildren: true); } catch { } } else if (outcome.Result == DiscardPlanningResult.BlockedByRunningChildren) { if (ConfirmAsync is null) return; await ConfirmAsync( $"{outcome.RunningChildrenCount} child task(s) are still running.\n" + "Cancel them first, then try again."); } } /// /// Wired by the view via . Returns true when the user confirms. /// public Func>? ConfirmAsync { get; set; } [RelayCommand] private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row) { if (row is null || _worker is null) return; try { await _worker.QueuePlanningSubtasksAsync(row.Id); } catch { } } [RelayCommand] private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row) { if (row is null) return; try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); } catch { } } [RelayCommand] private void ToggleExpand(TaskRowViewModel? row) { if (row is null) return; var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : row.IsExpanded); _expandedState[row.Id] = next; row.IsExpanded = next; Regroup(); } partial void OnSelectedTaskChanged(TaskRowViewModel? value) { foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value); SelectionChanged?.Invoke(this, EventArgs.Empty); } }