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

@@ -8,14 +8,21 @@ using ClaudeDo.Ui.ViewModels;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System; using System;
using System.Runtime.InteropServices;
namespace ClaudeDo.App; namespace ClaudeDo.App;
sealed class Program sealed class Program
{ {
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SetCurrentProcessExplicitAppUserModelID(string appId);
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App");
var services = BuildServices(); var services = BuildServices();
App.Services = services; App.Services = services;

View File

@@ -1,7 +1,11 @@
using System.Collections.ObjectModel;
using Avalonia.Media; using Avalonia.Media;
using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.ViewModels;
@@ -15,6 +19,11 @@ public partial class TaskItemViewModel : ViewModelBase
[ObservableProperty] private string? _description; [ObservableProperty] private string? _description;
[ObservableProperty] private TaskStatus _status; [ObservableProperty] private TaskStatus _status;
[ObservableProperty] private bool _isStarting; [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 Id { get; }
public string ListId { get; } public string ListId { get; }
@@ -23,9 +32,13 @@ public partial class TaskItemViewModel : ViewModelBase
private readonly Func<string, Task>? _runNow; private readonly Func<string, Task>? _runNow;
private readonly Func<bool> _canRunNow; private readonly Func<bool> _canRunNow;
private readonly Func<string, Task>? _toggleDone; private readonly Func<string, Task>? _toggleDone;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private bool _subtasksLoaded;
public TaskItemViewModel(TaskEntity entity, IReadOnlyList<TagEntity> tags, 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; Entity = entity;
Id = entity.Id; Id = entity.Id;
@@ -39,6 +52,9 @@ public partial class TaskItemViewModel : ViewModelBase
_runNow = runNow; _runNow = runNow;
_canRunNow = canRunNow; _canRunNow = canRunNow;
_toggleDone = toggleDone; _toggleDone = toggleDone;
_dbFactory = dbFactory;
_subtaskCount = subtaskCount;
_hasSubtasks = subtaskCount > 0;
} }
public bool IsDone => Status == TaskStatus.Done; public bool IsDone => Status == TaskStatus.Done;
@@ -104,4 +120,55 @@ public partial class TaskItemViewModel : ViewModelBase
if (_toggleDone is not null) if (_toggleDone is not null)
await _toggleDone(Id); 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(); using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context); var taskRepo = new TaskRepository(context);
var entities = await taskRepo.GetByListIdAsync(listId); 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) foreach (var e in entities)
{ {
var tags = await taskRepo.GetEffectiveTagsAsync(e.Id); 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) catch (Exception ex)
@@ -135,7 +142,8 @@ public partial class TaskListViewModel : ViewModelBase
var taskRepo = new TaskRepository(context); var taskRepo = new TaskRepository(context);
await taskRepo.AddAsync(entity); await taskRepo.AddAsync(entity);
var tags = await taskRepo.GetEffectiveTagsAsync(entity.Id); 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); Tasks.Add(vm);
SelectedTask = vm; SelectedTask = vm;
InlineAddTitle = ""; InlineAddTitle = "";
@@ -183,7 +191,8 @@ public partial class TaskListViewModel : ViewModelBase
} }
var tags = await taskRepo.GetEffectiveTagsAsync(saved.Id); 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 // Auto wake-queue if agent+queued
if (saved.Status == TaskStatus.Queued && if (saved.Status == TaskStatus.Queued &&
@@ -282,7 +291,11 @@ public partial class TaskListViewModel : ViewModelBase
} }
var tags = await taskRepo.GetEffectiveTagsAsync(taskId); var tags = await taskRepo.GetEffectiveTagsAsync(taskId);
if (existing is not null) if (existing is not null)
{
existing.Refresh(entity, tags); existing.Refresh(entity, tags);
var subtaskCount = await context.Subtasks.CountAsync(s => s.TaskId == taskId);
await existing.RefreshSubtasksAsync(subtaskCount);
}
} }
private async Task RunNowAsync(string taskId) private async Task RunNowAsync(string taskId)

View File

@@ -31,72 +31,128 @@
KeyDown="OnTaskListKeyDown"> KeyDown="OnTaskListKeyDown">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TaskItemViewModel"> <DataTemplate x:DataType="vm:TaskItemViewModel">
<Grid ColumnDefinitions="Auto,*" Margin="4,4" <Grid RowDefinitions="Auto,Auto"
Background="Transparent" Background="Transparent"
Opacity="{Binding RowOpacity}" Opacity="{Binding RowOpacity}">
DoubleTapped="OnTaskItemDoubleTapped" <!-- Row 0: Task row -->
PointerPressed="OnTaskItemPointerPressed"> <Grid Grid.Row="0" ColumnDefinitions="20,Auto,*" Margin="4,4"
<Grid.ContextFlyout> DoubleTapped="OnTaskItemDoubleTapped"
<MenuFlyout> PointerPressed="OnTaskItemPointerPressed">
<MenuItem Header="Edit" <Grid.ContextFlyout>
Command="{Binding #Root.((vm:TaskListViewModel)DataContext).EditTaskCommand}"/> <MenuFlyout>
<MenuItem Header="Delete" <MenuItem Header="Edit"
Command="{Binding #Root.((vm:TaskListViewModel)DataContext).DeleteTaskCommand}"/> Command="{Binding #Root.((vm:TaskListViewModel)DataContext).EditTaskCommand}"/>
<Separator/> <MenuItem Header="Delete"
<MenuItem Header="Run Now" Command="{Binding #Root.((vm:TaskListViewModel)DataContext).DeleteTaskCommand}"/>
Command="{Binding RunNowCommand}"/> <Separator/>
</MenuFlyout> <MenuItem Header="Run Now"
</Grid.ContextFlyout> Command="{Binding RunNowCommand}"/>
</MenuFlyout>
</Grid.ContextFlyout>
<!-- Circular checkbox --> <!-- Expand/collapse chevron -->
<Border Grid.Column="0" Width="22" Height="22" <Button Grid.Column="0"
CornerRadius="11" Command="{Binding ToggleExpandedCommand}"
BorderThickness="2" IsVisible="{Binding HasSubtasks}"
BorderBrush="{Binding StatusText, Converter={x:Static conv:CheckboxBorderConverter.Instance}}" Background="Transparent"
Background="Transparent" BorderThickness="0"
VerticalAlignment="Center" Margin="0,0,10,0" Padding="0"
Cursor="Hand" Width="16" Height="16"
PointerPressed="OnCheckboxPressed"> VerticalAlignment="Center"
<Panel> Cursor="Hand">
<!-- Checkmark for done --> <Panel>
<Canvas Width="12" Height="12" <Canvas Width="10" Height="10"
IsVisible="{Binding IsDone}" IsVisible="{Binding !IsExpanded}">
HorizontalAlignment="Center" VerticalAlignment="Center"> <Path Stroke="{StaticResource TextDimBrush}" StrokeThickness="1.5"
<Path Stroke="{StaticResource StatusGreenBrush}" StrokeThickness="2" Data="M 2,0 L 8,5 L 2,10"/>
Data="M 1,6 L 4.5,9.5 L 11,3"/> </Canvas>
</Canvas> <Canvas Width="10" Height="10"
<!-- Running dot --> IsVisible="{Binding IsExpanded}">
<Ellipse Width="8" Height="8" <Path Stroke="{StaticResource TextDimBrush}" StrokeThickness="1.5"
Fill="{StaticResource StatusOrangeBrush}" Data="M 0,2 L 5,8 L 10,2"/>
IsVisible="{Binding IsRunning}" </Canvas>
HorizontalAlignment="Center" VerticalAlignment="Center"/> </Panel>
<!-- Starting dot --> </Button>
<Ellipse Width="8" Height="8" Fill="#FFD700"
IsVisible="{Binding IsStarting}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Panel>
</Border>
<!-- Task content --> <!-- Circular checkbox -->
<StackPanel Grid.Column="1" VerticalAlignment="Center"> <Border Grid.Column="1" Width="22" Height="22"
<TextBlock Text="{Binding Title}" FontWeight="Medium" CornerRadius="11"
Foreground="{Binding TitleForeground}" BorderThickness="2"
TextDecorations="{Binding TitleDecorations}" BorderBrush="{Binding StatusText, Converter={x:Static conv:CheckboxBorderConverter.Instance}}"
TextTrimming="CharacterEllipsis"/> Background="Transparent"
<TextBlock FontSize="11" VerticalAlignment="Center" Margin="0,0,10,0"
Foreground="{StaticResource TextDimBrush}" Cursor="Hand"
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> PointerPressed="OnCheckboxPressed">
<TextBlock.Text> <Panel>
<MultiBinding StringFormat="{}{0} · {1}"> <Canvas Width="12" Height="12"
<Binding Path="TagsText"/> IsVisible="{Binding IsDone}"
<Binding Path="StatusText"/> HorizontalAlignment="Center" VerticalAlignment="Center">
</MultiBinding> <Path Stroke="{StaticResource StatusGreenBrush}" StrokeThickness="2"
</TextBlock.Text> Data="M 1,6 L 4.5,9.5 L 11,3"/>
</TextBlock> </Canvas>
<TextBlock Text="{Binding StatusText}" FontSize="11" <Ellipse Width="8" Height="8"
Foreground="{StaticResource TextDimBrush}" Fill="{StaticResource StatusOrangeBrush}"
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNullOrEmpty}}"/> IsVisible="{Binding IsRunning}"
</StackPanel> HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Ellipse Width="8" Height="8" Fill="#FFD700"
IsVisible="{Binding IsStarting}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Panel>
</Border>
<!-- Task content -->
<StackPanel Grid.Column="2" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" FontWeight="Medium"
Foreground="{Binding TitleForeground}"
TextDecorations="{Binding TitleDecorations}"
TextTrimming="CharacterEllipsis"/>
<TextBlock FontSize="11"
Foreground="{StaticResource TextDimBrush}"
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} · {1}">
<Binding Path="TagsText"/>
<Binding Path="StatusText"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<TextBlock Text="{Binding StatusText}" FontSize="11"
Foreground="{StaticResource TextDimBrush}"
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNullOrEmpty}}"/>
</StackPanel>
</Grid>
<!-- Row 1: Subtask list (visible when expanded) -->
<ItemsControl Grid.Row="1"
ItemsSource="{Binding Subtasks}"
IsVisible="{Binding IsExpanded}"
Margin="40,0,0,4">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
<Grid ColumnDefinitions="Auto,*" Margin="0,2"
PointerPressed="OnSubtaskPointerPressed">
<Grid.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Edit Task"
Command="{Binding #Root.((vm:TaskListViewModel)DataContext).EditTaskCommand}"/>
</MenuFlyout>
</Grid.ContextFlyout>
<CheckBox Grid.Column="0"
IsChecked="{Binding Completed, Mode=OneWay}"
VerticalAlignment="Center"
Margin="0,0,6,0"
MinWidth="0"
Click="OnSubtaskCheckboxClick"/>
<TextBlock Grid.Column="1"
Text="{Binding Title}"
FontSize="12"
Foreground="{StaticResource TextDimBrush}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>

View File

@@ -1,3 +1,4 @@
using System.Collections.ObjectModel;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; 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() public void FocusInlineAdd()
{ {
this.FindControl<TextBox>("InlineAddBox")?.Focus(); this.FindControl<TextBox>("InlineAddBox")?.Focus();