diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 68dbd87..8d274af 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -8,14 +8,21 @@ using ClaudeDo.Ui.ViewModels; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using System; +using System.Runtime.InteropServices; namespace ClaudeDo.App; sealed class Program { + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SetCurrentProcessExplicitAppUserModelID(string appId); + [STAThread] public static void Main(string[] args) { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App"); + var services = BuildServices(); App.Services = services; diff --git a/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs index a834084..bc2a70d 100644 --- a/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs @@ -1,7 +1,11 @@ +using System.Collections.ObjectModel; using Avalonia.Media; +using ClaudeDo.Data; using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.ViewModels; @@ -15,6 +19,11 @@ public partial class TaskItemViewModel : ViewModelBase [ObservableProperty] private string? _description; [ObservableProperty] private TaskStatus _status; [ObservableProperty] private bool _isStarting; + [ObservableProperty] private bool _isExpanded; + [ObservableProperty] private bool _hasSubtasks; + [ObservableProperty] private int _subtaskCount; + + public ObservableCollection Subtasks { get; } = new(); public string Id { get; } public string ListId { get; } @@ -23,9 +32,13 @@ public partial class TaskItemViewModel : ViewModelBase private readonly Func? _runNow; private readonly Func _canRunNow; private readonly Func? _toggleDone; + private readonly IDbContextFactory _dbFactory; + private bool _subtasksLoaded; public TaskItemViewModel(TaskEntity entity, IReadOnlyList tags, - Func? runNow, Func canRunNow, Func? toggleDone = null) + Func? runNow, Func canRunNow, + IDbContextFactory dbFactory, int subtaskCount, + Func? toggleDone = null) { Entity = entity; Id = entity.Id; @@ -39,6 +52,9 @@ public partial class TaskItemViewModel : ViewModelBase _runNow = runNow; _canRunNow = canRunNow; _toggleDone = toggleDone; + _dbFactory = dbFactory; + _subtaskCount = subtaskCount; + _hasSubtasks = subtaskCount > 0; } public bool IsDone => Status == TaskStatus.Done; @@ -104,4 +120,55 @@ public partial class TaskItemViewModel : ViewModelBase if (_toggleDone is not null) await _toggleDone(Id); } + + [RelayCommand] + private async Task ToggleExpanded() + { + IsExpanded = !IsExpanded; + if (IsExpanded && !_subtasksLoaded) + await LoadSubtasksAsync(); + } + + private async Task LoadSubtasksAsync() + { + using var context = _dbFactory.CreateDbContext(); + var repo = new SubtaskRepository(context); + var entities = await repo.GetByTaskIdAsync(Id); + Subtasks.Clear(); + foreach (var e in entities) + Subtasks.Add(SubtaskItemViewModel.From(e)); + _subtasksLoaded = true; + } + + [RelayCommand] + private async Task ToggleSubtaskDone(string subtaskId) + { + var vm = Subtasks.FirstOrDefault(s => s.Id == subtaskId); + if (vm is null) return; + vm.Completed = !vm.Completed; + + using var context = _dbFactory.CreateDbContext(); + var entity = await context.Subtasks.FindAsync(subtaskId); + if (entity is not null) + { + entity.Completed = vm.Completed; + await context.SaveChangesAsync(); + } + } + + public async Task RefreshSubtasksAsync(int newCount) + { + SubtaskCount = newCount; + HasSubtasks = newCount > 0; + if (!HasSubtasks) + { + IsExpanded = false; + Subtasks.Clear(); + _subtasksLoaded = false; + } + else if (_subtasksLoaded || IsExpanded) + { + await LoadSubtasksAsync(); + } + } } diff --git a/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs index 1df6795..fc2eae9 100644 --- a/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs @@ -91,10 +91,17 @@ public partial class TaskListViewModel : ViewModelBase using var context = _dbFactory.CreateDbContext(); var taskRepo = new TaskRepository(context); var entities = await taskRepo.GetByListIdAsync(listId); + var taskIds = entities.Select(e => e.Id).ToList(); + var subtaskCounts = await context.Subtasks + .Where(s => taskIds.Contains(s.TaskId)) + .GroupBy(s => s.TaskId) + .ToDictionaryAsync(g => g.Key, g => g.Count()); foreach (var e in entities) { var tags = await taskRepo.GetEffectiveTagsAsync(e.Id); - Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync)); + subtaskCounts.TryGetValue(e.Id, out var count); + Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected, + _dbFactory, count, ToggleDoneAsync)); } } catch (Exception ex) @@ -135,7 +142,8 @@ public partial class TaskListViewModel : ViewModelBase var taskRepo = new TaskRepository(context); await taskRepo.AddAsync(entity); var tags = await taskRepo.GetEffectiveTagsAsync(entity.Id); - var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync); + var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected, + _dbFactory, 0, ToggleDoneAsync); Tasks.Add(vm); SelectedTask = vm; InlineAddTitle = ""; @@ -183,7 +191,8 @@ public partial class TaskListViewModel : ViewModelBase } var tags = await taskRepo.GetEffectiveTagsAsync(saved.Id); - Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync)); + Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected, + _dbFactory, 0, ToggleDoneAsync)); // Auto wake-queue if agent+queued if (saved.Status == TaskStatus.Queued && @@ -282,7 +291,11 @@ public partial class TaskListViewModel : ViewModelBase } var tags = await taskRepo.GetEffectiveTagsAsync(taskId); if (existing is not null) + { existing.Refresh(entity, tags); + var subtaskCount = await context.Subtasks.CountAsync(s => s.TaskId == taskId); + await existing.RefreshSubtasksAsync(subtaskCount); + } } private async Task RunNowAsync(string taskId) diff --git a/src/ClaudeDo.Ui/Views/TaskListView.axaml b/src/ClaudeDo.Ui/Views/TaskListView.axaml index 8115794..5506e83 100644 --- a/src/ClaudeDo.Ui/Views/TaskListView.axaml +++ b/src/ClaudeDo.Ui/Views/TaskListView.axaml @@ -31,72 +31,128 @@ KeyDown="OnTaskListKeyDown"> - - - - - - - - - + Opacity="{Binding RowOpacity}"> + + + + + + + + + + - - - - - - - - - - - - - + + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/TaskListView.axaml.cs b/src/ClaudeDo.Ui/Views/TaskListView.axaml.cs index c739070..5e1ca90 100644 --- a/src/ClaudeDo.Ui/Views/TaskListView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/TaskListView.axaml.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; @@ -97,6 +98,29 @@ public partial class TaskListView : UserControl } } + private void OnSubtaskPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(null).Properties.IsRightButtonPressed + && sender is Control { DataContext: SubtaskItemViewModel subtask } + && DataContext is TaskListViewModel vm) + { + var parent = vm.Tasks.FirstOrDefault(t => t.Subtasks.Contains(subtask)); + if (parent is not null) + vm.SelectedTask = parent; + } + } + + private async void OnSubtaskCheckboxClick(object? sender, RoutedEventArgs e) + { + if (sender is CheckBox { DataContext: SubtaskItemViewModel subtask } + && DataContext is TaskListViewModel vm) + { + var parent = vm.Tasks.FirstOrDefault(t => t.Subtasks.Contains(subtask)); + if (parent is not null) + await parent.ToggleSubtaskDoneCommand.ExecuteAsync(subtask.Id); + } + } + public void FocusInlineAdd() { this.FindControl("InlineAddBox")?.Focus();