using System.Collections.ObjectModel; using System.Globalization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; using ClaudeDo.Data.Models; 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; public event EventHandler? SelectionChanged; public event EventHandler? FocusAddTaskRequested; public event EventHandler? TasksChanged; public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty); 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 = "COMPLETED"; public Func? ShowUnfinishedPlanningModal { get; set; } public TasksIslandViewModel(IDbContextFactory dbFactory, IWorkerClient? worker = null) { _dbFactory = dbFactory; _worker = worker; } public void LoadForList(ListNavItemViewModel? list) { _loadCts?.Cancel(); _loadCts?.Dispose(); _loadCts = new CancellationTokenSource(); var ct = _loadCts.Token; _currentList = list; Items.Clear(); OverdueItems.Clear(); OpenItems.Clear(); CompletedItems.Clear(); HasOverdue = false; HasOpen = false; HasCompleted = false; ShowOpenLabel = false; if (list is null) return; HeaderTitle = list.Name; HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant(); _ = 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(); IEnumerable filtered = list.Kind switch { ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay), ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred), ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null), ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t => t.Status == TaskStatus.Queued), ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running), ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active), ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id), _ => Enumerable.Empty(), }; foreach (var t in filtered) Items.Add(TaskRowViewModel.FromEntity(t)); Regroup(); UpdateSubtitle(); } catch (OperationCanceledException) { } } internal void Regroup() { OverdueItems.Clear(); OpenItems.Clear(); CompletedItems.Clear(); // 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. var topLevel = Items.Where(r => !r.IsChild); var flat = new List(); foreach (var parent in topLevel) { flat.Add(parent); if (parent.IsPlanningParent && parent.IsExpanded) { var children = Items.Where(r => r.ParentTaskId == parent.Id); flat.AddRange(children); } } var today = DateTime.Today; foreach (var r in flat) { if (r.Done) 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 = $"COMPLETED · {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); 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.Manual; row.Status = entity.Status; await db.SaveChangesAsync(); } 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); } [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; entity.Status = TaskStatus.Manual; await db.SaveChangesAsync(); row.Status = TaskStatus.Manual; Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } 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 || row.Status != TaskStatus.Manual) return; try { await _worker!.StartPlanningSessionAsync(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: await _worker.ResumePlanningSessionAsync(row.Id); break; case UnfinishedPlanningModalResult.FinalizeNow: await _worker.FinalizePlanningSessionAsync(row.Id); break; case UnfinishedPlanningModalResult.Discard: await _worker.DiscardPlanningSessionAsync(row.Id); break; case UnfinishedPlanningModalResult.Cancel: default: break; } } catch { } } [RelayCommand] private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row) { if (row is null) return; try { await _worker!.DiscardPlanningSessionAsync(row.Id); } catch { } } [RelayCommand] private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row) { if (row is null) return; try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); } 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); } }