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>
347 lines
12 KiB
C#
347 lines
12 KiB
C#
using System.Collections.ObjectModel;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.ApplicationLifetimes;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.Views;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels;
|
|
|
|
public partial class TaskListViewModel : ViewModelBase
|
|
{
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly WorkerClient _worker;
|
|
private readonly Func<TaskEditorViewModel> _editorFactory;
|
|
private readonly Action<string> _showMessage;
|
|
|
|
public ObservableCollection<TaskItemViewModel> Tasks { get; } = new();
|
|
|
|
[ObservableProperty] private TaskItemViewModel? _selectedTask;
|
|
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddTaskCommand))] private string? _currentListId;
|
|
[ObservableProperty] private string _listName = "Tasks";
|
|
[ObservableProperty] private string _inlineAddTitle = "";
|
|
|
|
public event Action<TaskItemViewModel?>? SelectedTaskChanged;
|
|
|
|
partial void OnSelectedTaskChanged(TaskItemViewModel? value) =>
|
|
SelectedTaskChanged?.Invoke(value);
|
|
|
|
public TaskListViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker,
|
|
Func<TaskEditorViewModel> editorFactory, Action<string> showMessage)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_worker = worker;
|
|
_editorFactory = editorFactory;
|
|
_showMessage = showMessage;
|
|
|
|
worker.TaskUpdatedEvent += OnTaskUpdated;
|
|
worker.TaskFinishedEvent += (_, taskId, _, _) => OnTaskUpdated(taskId);
|
|
worker.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
|
{
|
|
foreach (var t in Tasks)
|
|
t.RunNowCommand.NotifyCanExecuteChanged();
|
|
});
|
|
};
|
|
|
|
worker.RunNowRequestedEvent += taskId =>
|
|
{
|
|
var item = Tasks.FirstOrDefault(t => t.Id == taskId);
|
|
item?.SetStarting();
|
|
};
|
|
|
|
worker.TaskStartedEvent += (_, taskId, _) =>
|
|
{
|
|
var item = Tasks.FirstOrDefault(t => t.Id == taskId);
|
|
item?.ClearStarting();
|
|
};
|
|
}
|
|
|
|
public async Task LoadAsync(string? listId)
|
|
{
|
|
CurrentListId = listId;
|
|
Tasks.Clear();
|
|
SelectedTask = null;
|
|
|
|
if (listId is not null)
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var listRepo = new ListRepository(context);
|
|
var list = await listRepo.GetByIdAsync(listId);
|
|
ListName = list?.Name ?? "Tasks";
|
|
}
|
|
else
|
|
{
|
|
ListName = "Tasks";
|
|
}
|
|
|
|
if (listId is null) return;
|
|
|
|
try
|
|
{
|
|
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);
|
|
subtaskCounts.TryGetValue(e.Id, out var count);
|
|
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected,
|
|
_dbFactory, count, ToggleDoneAsync));
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_showMessage($"Error loading tasks: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private bool CanAddTask() => CurrentListId is not null;
|
|
|
|
[RelayCommand(CanExecute = nameof(CanAddTask))]
|
|
private async Task InlineAdd()
|
|
{
|
|
var title = InlineAddTitle.Trim();
|
|
if (string.IsNullOrEmpty(title) || CurrentListId is null) return;
|
|
|
|
string defaultCommitType;
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var listRepo = new ListRepository(context);
|
|
var list = await listRepo.GetByIdAsync(CurrentListId);
|
|
defaultCommitType = list?.DefaultCommitType ?? "chore";
|
|
}
|
|
|
|
var entity = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = CurrentListId,
|
|
Title = title,
|
|
Status = TaskStatus.Manual,
|
|
CommitType = defaultCommitType,
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
|
|
try
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
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,
|
|
_dbFactory, 0, ToggleDoneAsync);
|
|
Tasks.Add(vm);
|
|
SelectedTask = vm;
|
|
InlineAddTitle = "";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_showMessage($"Error creating task: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
[RelayCommand(CanExecute = nameof(CanAddTask))]
|
|
private async Task AddTask()
|
|
{
|
|
string defaultCommitType;
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var listRepo = new ListRepository(context);
|
|
var list = await listRepo.GetByIdAsync(CurrentListId);
|
|
defaultCommitType = list?.DefaultCommitType ?? "chore";
|
|
}
|
|
|
|
var editor = _editorFactory();
|
|
await editor.LoadAgentsAsync(_worker);
|
|
editor.InitForCreate(CurrentListId, defaultCommitType);
|
|
|
|
var window = new TaskEditorView { DataContext = editor };
|
|
editor.RequestClose += () => window.Close();
|
|
window.Closed += (_, _) => editor.OnWindowClosed();
|
|
_ = ShowDialogAsync(window);
|
|
|
|
var saved = await editor.ShowAndWaitAsync();
|
|
if (saved is null) return;
|
|
|
|
try
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var taskRepo = new TaskRepository(context);
|
|
var tagRepo = new TagRepository(context);
|
|
await taskRepo.AddAsync(saved);
|
|
|
|
foreach (var tagName in editor.SelectedTagNames)
|
|
{
|
|
var tagId = await tagRepo.GetOrCreateAsync(tagName);
|
|
await taskRepo.AddTagAsync(saved.Id, tagId);
|
|
}
|
|
|
|
var tags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
|
|
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected,
|
|
_dbFactory, 0, ToggleDoneAsync));
|
|
|
|
// Auto wake-queue if agent+queued
|
|
if (saved.Status == TaskStatus.Queued &&
|
|
tags.Any(t => t.Name == "agent"))
|
|
{
|
|
try { await _worker.WakeQueueAsync(); }
|
|
catch { /* worker offline is fine */ }
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_showMessage($"Error creating task: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task EditTask()
|
|
{
|
|
if (SelectedTask is null || CurrentListId is null) return;
|
|
|
|
TaskEntity? entity;
|
|
List<TagEntity> taskTags;
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var taskRepo = new TaskRepository(context);
|
|
entity = await taskRepo.GetByIdAsync(SelectedTask.Id);
|
|
if (entity is null) return;
|
|
taskTags = await taskRepo.GetTagsAsync(entity.Id);
|
|
}
|
|
|
|
var editor = _editorFactory();
|
|
await editor.LoadAgentsAsync(_worker);
|
|
await editor.InitForEditAsync(entity, taskTags);
|
|
|
|
var window = new TaskEditorView { DataContext = editor };
|
|
editor.RequestClose += () => window.Close();
|
|
window.Closed += (_, _) => editor.OnWindowClosed();
|
|
_ = ShowDialogAsync(window);
|
|
|
|
var saved = await editor.ShowAndWaitAsync();
|
|
if (saved is null) return;
|
|
|
|
try
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var taskRepo = new TaskRepository(context);
|
|
var tagRepo = new TagRepository(context);
|
|
await taskRepo.UpdateAsync(saved);
|
|
|
|
var existingTags = await taskRepo.GetTagsAsync(saved.Id);
|
|
foreach (var old in existingTags)
|
|
await taskRepo.RemoveTagAsync(saved.Id, old.Id);
|
|
foreach (var tagName in editor.SelectedTagNames)
|
|
{
|
|
var tagId = await tagRepo.GetOrCreateAsync(tagName);
|
|
await taskRepo.AddTagAsync(saved.Id, tagId);
|
|
}
|
|
|
|
var newTags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
|
|
SelectedTask.Refresh(saved, newTags);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_showMessage($"Error updating task: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task DeleteTask()
|
|
{
|
|
if (SelectedTask is null) return;
|
|
try
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var taskRepo = new TaskRepository(context);
|
|
await taskRepo.DeleteAsync(SelectedTask.Id);
|
|
Tasks.Remove(SelectedTask);
|
|
SelectedTask = null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_showMessage($"Error deleting task: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
public async Task RefreshSingleAsync(string taskId)
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var taskRepo = new TaskRepository(context);
|
|
var entity = await taskRepo.GetByIdAsync(taskId);
|
|
var existing = Tasks.FirstOrDefault(t => t.Id == taskId);
|
|
if (entity is null)
|
|
{
|
|
if (existing is not null) Tasks.Remove(existing);
|
|
return;
|
|
}
|
|
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)
|
|
{
|
|
try
|
|
{
|
|
await _worker.RunNowAsync(taskId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_showMessage($"RunNow failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task ToggleDoneAsync(string taskId)
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var taskRepo = new TaskRepository(context);
|
|
var entity = await taskRepo.GetByIdAsync(taskId);
|
|
if (entity is null) return;
|
|
|
|
entity.Status = entity.Status == TaskStatus.Done ? TaskStatus.Manual : TaskStatus.Done;
|
|
if (entity.Status == TaskStatus.Done)
|
|
entity.FinishedAt = DateTime.UtcNow;
|
|
|
|
await taskRepo.UpdateAsync(entity);
|
|
await RefreshSingleAsync(taskId);
|
|
}
|
|
|
|
private async void OnTaskUpdated(string taskId)
|
|
{
|
|
if (CurrentListId is null) return;
|
|
await RefreshSingleAsync(taskId);
|
|
}
|
|
|
|
private static async Task ShowDialogAsync(Window dialog)
|
|
{
|
|
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
|
|
&& desktop.MainWindow is not null)
|
|
{
|
|
await dialog.ShowDialog(desktop.MainWindow);
|
|
}
|
|
else
|
|
{
|
|
dialog.Show();
|
|
}
|
|
}
|
|
}
|