diff --git a/src/ClaudeDo.App/App.axaml b/src/ClaudeDo.App/App.axaml index c2f87da..a2cce75 100644 --- a/src/ClaudeDo.App/App.axaml +++ b/src/ClaudeDo.App/App.axaml @@ -2,14 +2,49 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ClaudeDo.App.App" xmlns:local="using:ClaudeDo.App" - RequestedThemeVariant="Default"> - + RequestedThemeVariant="Dark"> + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + \ No newline at end of file diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index e5842db..7a5ce37 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -60,7 +60,13 @@ sealed class Program sc.AddTransient(); sc.AddTransient(); sc.AddSingleton(); - sc.AddSingleton(); + sc.AddSingleton(sp => new TaskDetailViewModel( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); sc.AddSingleton(sp => { var taskRepo = sp.GetRequiredService(); diff --git a/src/ClaudeDo.Ui/Converters/CheckboxBorderConverter.cs b/src/ClaudeDo.Ui/Converters/CheckboxBorderConverter.cs new file mode 100644 index 0000000..9e9e20f --- /dev/null +++ b/src/ClaudeDo.Ui/Converters/CheckboxBorderConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace ClaudeDo.Ui.Converters; + +public sealed class CheckboxBorderConverter : IValueConverter +{ + public static readonly CheckboxBorderConverter Instance = new(); + + private static readonly ISolidColorBrush Gray = new SolidColorBrush(Color.Parse("#475569")); + private static readonly ISolidColorBrush Orange = new SolidColorBrush(Color.Parse("#e67e22")); + private static readonly ISolidColorBrush Green = new SolidColorBrush(Color.Parse("#3d9474")); + private static readonly ISolidColorBrush Red = new SolidColorBrush(Color.Parse("#ef4444")); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value?.ToString()?.ToLowerInvariant() switch + { + "running" => Orange, + "done" => Green, + "failed" => Red, + _ => Gray, // manual, queued + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/ClaudeDo.Ui/ViewModels/ListItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/ListItemViewModel.cs index 889a94c..8be03d5 100644 --- a/src/ClaudeDo.Ui/ViewModels/ListItemViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/ListItemViewModel.cs @@ -1,3 +1,5 @@ +using System; +using Avalonia.Media; using ClaudeDo.Data.Models; using CommunityToolkit.Mvvm.ComponentModel; @@ -9,6 +11,17 @@ public partial class ListItemViewModel : ViewModelBase [ObservableProperty] private string? _workingDir; [ObservableProperty] private string _defaultCommitType; + private static readonly IBrush[] DotPalette = + [ + new SolidColorBrush(Color.Parse("#3d9474")), // green + new SolidColorBrush(Color.Parse("#5571a1")), // blue + new SolidColorBrush(Color.Parse("#d4964a")), // amber + new SolidColorBrush(Color.Parse("#7c6aad")), // purple + new SolidColorBrush(Color.Parse("#c25d6a")), // rose + ]; + + public IBrush DotBrush => DotPalette[Math.Abs(Id.GetHashCode()) % DotPalette.Length]; + public string Id { get; } public ListItemViewModel(ListEntity entity) diff --git a/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs index dc853dd..4b3d573 100644 --- a/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs @@ -41,6 +41,7 @@ public partial class MainWindowViewModel : ViewModelBase StatusBar = statusBar; TaskList.SelectedTaskChanged += OnSelectedTaskChanged; + TaskDetail.TaskChanged += taskId => _ = TaskList.RefreshSingleAsync(taskId); } public async Task InitializeAsync() diff --git a/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs index ce09b6d..c6959f6 100644 --- a/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs @@ -16,12 +16,18 @@ public partial class TaskDetailViewModel : ViewModelBase private readonly ListRepository _listRepo; private readonly GitService _git; private readonly WorkerClient _worker; + private readonly TagRepository _tagRepo; [ObservableProperty] private string _title = ""; [ObservableProperty] private string? _description; [ObservableProperty] private string? _result; [ObservableProperty] private string? _logPath; [ObservableProperty] private string _statusText = ""; + [ObservableProperty] private string _statusChoice = "Manual"; + [ObservableProperty] private string _commitType = "chore"; + + public static string[] StatusChoices { get; } = ["Manual", "Queued", "Running", "Done", "Failed"]; + public static string[] CommitTypes { get; } = ["feat", "fix", "refactor", "docs", "test", "chore", "ci", "perf", "style", "build"]; // Worktree [ObservableProperty] private bool _hasWorktree; @@ -32,19 +38,25 @@ public partial class TaskDetailViewModel : ViewModelBase // Live stream public ObservableCollection LiveLines { get; } = new(); + public ObservableCollection Tags { get; } = new(); + [ObservableProperty] private string _newTagInput = ""; private string? _taskId; private string? _listId; + private bool _isLoading; private const int MaxLiveLines = 500; + public event Action? TaskChanged; + public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo, - ListRepository listRepo, GitService git, WorkerClient worker) + ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo) { _taskRepo = taskRepo; _worktreeRepo = worktreeRepo; _listRepo = listRepo; _git = git; _worker = worker; + _tagRepo = tagRepo; worker.TaskMessageEvent += OnTaskMessage; worker.WorktreeUpdatedEvent += OnWorktreeUpdated; @@ -59,16 +71,77 @@ public partial class TaskDetailViewModel : ViewModelBase var task = await _taskRepo.GetByIdAsync(taskId); if (task is null) return; - _listId = task.ListId; - Title = task.Title; - Description = task.Description; - Result = task.Result; - LogPath = task.LogPath; - StatusText = task.Status.ToString().ToLowerInvariant(); + _isLoading = true; + try + { + _listId = task.ListId; + Title = task.Title; + Description = task.Description; + Result = task.Result; + LogPath = task.LogPath; + StatusText = task.Status.ToString().ToLowerInvariant(); + StatusChoice = task.Status.ToString(); + CommitType = task.CommitType; + + Tags.Clear(); + var tags = await _taskRepo.GetTagsAsync(taskId); + foreach (var tag in tags) + Tags.Add(tag); + } + finally + { + _isLoading = false; + } await LoadWorktreeAsync(taskId); } + public async Task SaveAsync() + { + if (_isLoading || _taskId is null) return; + + var entity = await _taskRepo.GetByIdAsync(_taskId); + if (entity is null) return; + + entity.Title = Title; + entity.Description = Description; + entity.CommitType = CommitType; + + if (Enum.TryParse(StatusChoice, true, out var status)) + entity.Status = status; + + await _taskRepo.UpdateAsync(entity); + StatusText = entity.Status.ToString().ToLowerInvariant(); + TaskChanged?.Invoke(_taskId); + } + + [RelayCommand] + private async Task AddTag() + { + var name = NewTagInput.Trim(); + if (string.IsNullOrEmpty(name) || _taskId is null) return; + + var tagId = await _tagRepo.GetOrCreateAsync(name); + await _taskRepo.AddTagAsync(_taskId, tagId); + + Tags.Clear(); + var tags = await _taskRepo.GetTagsAsync(_taskId); + foreach (var tag in tags) + Tags.Add(tag); + + NewTagInput = ""; + TaskChanged?.Invoke(_taskId); + } + + [RelayCommand] + private async Task RemoveTag(TagEntity tag) + { + if (_taskId is null) return; + await _taskRepo.RemoveTagAsync(_taskId, tag.Id); + Tags.Remove(tag); + TaskChanged?.Invoke(_taskId); + } + public void Clear() { _taskId = null; @@ -80,6 +153,10 @@ public partial class TaskDetailViewModel : ViewModelBase StatusText = ""; HasWorktree = false; LiveLines.Clear(); + Tags.Clear(); + NewTagInput = ""; + StatusChoice = "Manual"; + CommitType = "chore"; } private async Task LoadWorktreeAsync(string taskId) diff --git a/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs index bf4c3d7..5dade3c 100644 --- a/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs @@ -1,3 +1,4 @@ +using Avalonia.Media; using ClaudeDo.Data.Models; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -20,9 +21,10 @@ public partial class TaskItemViewModel : ViewModelBase private readonly Func? _runNow; private readonly Func _canRunNow; + private readonly Func? _toggleDone; public TaskItemViewModel(TaskEntity entity, IReadOnlyList tags, - Func? runNow, Func canRunNow) + Func? runNow, Func canRunNow, Func? toggleDone = null) { Entity = entity; Id = entity.Id; @@ -35,8 +37,23 @@ public partial class TaskItemViewModel : ViewModelBase _description = entity.Description; _runNow = runNow; _canRunNow = canRunNow; + _toggleDone = toggleDone; } + public bool IsDone => Status == TaskStatus.Done; + public bool IsRunning => Status == TaskStatus.Running; + public bool CanToggleDone => Status != TaskStatus.Running && Status != TaskStatus.Failed; + + public TextDecorationCollection? TitleDecorations => IsDone + ? TextDecorations.Strikethrough + : null; + + public IBrush TitleForeground => IsDone + ? new SolidColorBrush(Color.Parse("#5a6578")) + : new SolidColorBrush(Color.Parse("#e2e8f0")); + + public double RowOpacity => IsDone ? 0.6 : 1.0; + public void Refresh(TaskEntity entity, IReadOnlyList tags) { Entity = entity; @@ -47,6 +64,13 @@ public partial class TaskItemViewModel : ViewModelBase CommitType = entity.CommitType; Description = entity.Description; RunNowCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(IsDone)); + OnPropertyChanged(nameof(IsRunning)); + OnPropertyChanged(nameof(CanToggleDone)); + OnPropertyChanged(nameof(TitleDecorations)); + OnPropertyChanged(nameof(TitleForeground)); + OnPropertyChanged(nameof(RowOpacity)); + ToggleDoneCommand.NotifyCanExecuteChanged(); } [RelayCommand(CanExecute = nameof(CanRunNow))] @@ -58,4 +82,11 @@ public partial class TaskItemViewModel : ViewModelBase private bool CanRunNow() => _canRunNow() && Status == TaskStatus.Queued; + + [RelayCommand(CanExecute = nameof(CanToggleDone))] + private async Task ToggleDone() + { + if (_toggleDone is not null) + await _toggleDone(Id); + } } diff --git a/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs index c81b37a..baf25f7 100644 --- a/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs @@ -25,6 +25,8 @@ public partial class TaskListViewModel : ViewModelBase [ObservableProperty] private TaskItemViewModel? _selectedTask; [ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddTaskCommand))] private string? _currentListId; + [ObservableProperty] private string _listName = "Tasks"; + [ObservableProperty] private string _inlineAddTitle = ""; public event Action? SelectedTaskChanged; @@ -61,6 +63,16 @@ public partial class TaskListViewModel : ViewModelBase Tasks.Clear(); SelectedTask = null; + if (listId is not null) + { + var list = await _listRepo.GetByIdAsync(listId); + ListName = list?.Name ?? "Tasks"; + } + else + { + ListName = "Tasks"; + } + if (listId is null) return; try @@ -69,7 +81,7 @@ public partial class TaskListViewModel : ViewModelBase foreach (var e in entities) { var tags = await _taskRepo.GetEffectiveTagsAsync(e.Id); - Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected)); + Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync)); } } catch (Exception ex) @@ -80,6 +92,40 @@ public partial class TaskListViewModel : ViewModelBase 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; + + var list = await _listRepo.GetByIdAsync(CurrentListId); + var 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 + { + await _taskRepo.AddAsync(entity); + var tags = await _taskRepo.GetEffectiveTagsAsync(entity.Id); + var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync); + Tasks.Add(vm); + SelectedTask = vm; + InlineAddTitle = ""; + } + catch (Exception ex) + { + _showMessage($"Error creating task: {ex.Message}"); + } + } + [RelayCommand(CanExecute = nameof(CanAddTask))] private async Task AddTask() { @@ -108,7 +154,7 @@ public partial class TaskListViewModel : ViewModelBase } var tags = await _taskRepo.GetEffectiveTagsAsync(saved.Id); - Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected)); + Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync)); // Auto wake-queue if agent+queued if (saved.Status == TaskStatus.Queued && @@ -206,6 +252,19 @@ public partial class TaskListViewModel : ViewModelBase } } + private async Task ToggleDoneAsync(string taskId) + { + 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; diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml b/src/ClaudeDo.Ui/Views/MainWindow.axaml index 0fe8f56..fef54ce 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml @@ -7,60 +7,97 @@ mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="700" x:Class="ClaudeDo.Ui.Views.MainWindow" x:DataType="vm:MainWindowViewModel" - Title="ClaudeDo" Width="1200" Height="700" - MinWidth="800" MinHeight="500"> + Title="ClaudeDo" + MinWidth="800" MinHeight="500" + KeyDown="OnGlobalKeyDown"> - - + - - - - - - - - -