feat(ui): add subtask tree view with expand/collapse in task list

Tasks with subtasks show a chevron for inline expand/collapse.
Subtask checkboxes toggle completion state directly. Also sets
Windows AppUserModelID for proper taskbar identity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-16 12:16:22 +02:00
parent 4ca48044db
commit 32bb52875f
5 changed files with 234 additions and 67 deletions

View File

@@ -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<SubtaskItemViewModel> Subtasks { get; } = new();
public string Id { get; }
public string ListId { get; }
@@ -23,9 +32,13 @@ public partial class TaskItemViewModel : ViewModelBase
private readonly Func<string, Task>? _runNow;
private readonly Func<bool> _canRunNow;
private readonly Func<string, Task>? _toggleDone;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private bool _subtasksLoaded;
public TaskItemViewModel(TaskEntity entity, IReadOnlyList<TagEntity> tags,
Func<string, Task>? runNow, Func<bool> canRunNow, Func<string, Task>? toggleDone = null)
Func<string, Task>? runNow, Func<bool> canRunNow,
IDbContextFactory<ClaudeDoDbContext> dbFactory, int subtaskCount,
Func<string, Task>? 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();
}
}
}

View File

@@ -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)