From c1856657b50e65d0e7d4f49a5852d2e6c7d3f746 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 29 Apr 2026 10:40:03 +0200 Subject: [PATCH] feat(ui): editable task status and tags from details panel Adds a status ComboBox in the Details header (no transition guards) and a Tags section with chips + AutoCompleteBox. TaskRowViewModel.Tags becomes an ObservableCollection so chip lists stay live. TasksIsland caches AllTags for the row context menu and exposes Set/Toggle helpers. Test fakes updated for the new IWorkerClient methods. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ClaudeDo.Ui/Services/IWorkerClient.cs | 4 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 22 ++++ .../Islands/DetailsIslandViewModel.cs | 109 +++++++++++++++++- .../ViewModels/Islands/TaskRowViewModel.cs | 17 ++- .../Islands/TasksIslandViewModel.cs | 40 +++++++ .../Views/Islands/DetailsIslandView.axaml | 52 ++++++++- .../ConflictResolutionViewModelTests.cs | 3 + .../ViewModels/DetailsIslandPlanningTests.cs | 3 + .../ViewModels/PlanningDiffViewModelTests.cs | 3 + .../UiVm/TasksIslandViewModelPlanningTests.cs | 3 + 10 files changed, 252 insertions(+), 4 deletions(-) diff --git a/src/ClaudeDo.Ui/Services/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/IWorkerClient.cs index c03389c..1a618b6 100644 --- a/src/ClaudeDo.Ui/Services/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/IWorkerClient.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.Services; @@ -27,6 +28,9 @@ public interface IWorkerClient : INotifyPropertyChanged Task> GetAgentsAsync(); Task GetListConfigAsync(string listId); Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto); + Task SetTaskStatusAsync(string taskId, TaskStatus status); + Task SetTaskTagsAsync(string taskId, IEnumerable tagNames); + Task> GetAllTagsAsync(); Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default); Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default); Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 79e3698..7baf9a9 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -381,6 +381,28 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await _hub.InvokeAsync("UpdateTaskAgentSettings", dto); } + public async Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status) + { + await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); + } + + public async Task SetTaskTagsAsync(string taskId, IEnumerable tagNames) + { + await _hub.InvokeAsync("SetTaskTags", taskId, tagNames.ToArray()); + } + + public async Task> GetAllTagsAsync() + { + try + { + return await _hub.InvokeAsync>("GetAllTags") ?? new List(); + } + catch + { + return new List(); + } + } + public async Task CleanupFinishedWorktreesAsync() { try diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 9773f2d..935d897 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -57,6 +57,62 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : ""; // Agent strip fields + // Status editor (Details panel) — set freely; broadcast refreshes other panes. + public System.Collections.ObjectModel.ObservableCollection StatusOptions { get; } = new() + { + ClaudeDo.Data.Models.TaskStatus.Idle, + ClaudeDo.Data.Models.TaskStatus.Queued, + ClaudeDo.Data.Models.TaskStatus.Running, + ClaudeDo.Data.Models.TaskStatus.Done, + ClaudeDo.Data.Models.TaskStatus.Failed, + ClaudeDo.Data.Models.TaskStatus.Cancelled, + }; + + private bool _suppressStatusSave; + [ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _selectedStatus; + + partial void OnSelectedStatusChanged(ClaudeDo.Data.Models.TaskStatus value) + { + if (_suppressStatusSave || Task is null) return; + _ = SaveStatusAsync(value); + } + + private async System.Threading.Tasks.Task SaveStatusAsync(ClaudeDo.Data.Models.TaskStatus value) + { + if (Task is null) return; + try { await _worker.SetTaskStatusAsync(Task.Id, value); } + catch { /* offline */ } + } + + // Tag editor + public ObservableCollection Tags { get; } = new(); + public ObservableCollection AvailableTags { get; } = new(); + [ObservableProperty] private string _newTagInput = ""; + + [RelayCommand] + private async System.Threading.Tasks.Task AddTagAsync() + { + if (Task is null) return; + var name = NewTagInput?.Trim().ToLowerInvariant(); + NewTagInput = ""; + if (string.IsNullOrEmpty(name)) return; + if (Tags.Contains(name)) return; + var next = Tags.ToList(); + next.Add(name); + try { await _worker.SetTaskTagsAsync(Task.Id, next); } + catch { /* offline */ } + } + + [RelayCommand] + private async System.Threading.Tasks.Task RemoveTagAsync(string? tagName) + { + if (Task is null || string.IsNullOrWhiteSpace(tagName)) return; + if (!Tags.Contains(tagName)) return; + var next = Tags.Where(t => t != tagName).ToList(); + try { await _worker.SetTaskTagsAsync(Task.Id, next); } + catch { /* offline */ } + } + [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(RunNowCommand))] private string _agentStatusLabel = "Idle"; @@ -181,6 +237,44 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase // Set by the view so DeleteTaskCommand can show an error message public Func? ShowErrorAsync { get; set; } + private void ApplyTagsFromEntity(ClaudeDo.Data.Models.TaskEntity entity) + { + Tags.Clear(); + foreach (var t in entity.Tags) Tags.Add(t.Name); + } + + private async System.Threading.Tasks.Task RefreshAvailableTagsAsync() + { + try + { + var all = await _worker.GetAllTagsAsync(); + AvailableTags.Clear(); + foreach (var t in all) AvailableTags.Add(t); + } + catch { } + } + + private async System.Threading.Tasks.Task RefreshTagsAndStatusAsync(string taskId) + { + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var entity = await ctx.Tasks + .AsNoTracking() + .Include(t => t.Tags) + .FirstOrDefaultAsync(t => t.Id == taskId); + if (entity is null || Task?.Id != taskId) return; + + _suppressStatusSave = true; + try { SelectedStatus = entity.Status; } + finally { _suppressStatusSave = false; } + AgentStatusLabel = entity.Status.ToString(); + ApplyTagsFromEntity(entity); + await RefreshAvailableTagsAsync(); + } + catch { } + } + public DetailsIslandViewModel(IDbContextFactory dbFactory, IWorkerClient worker, IServiceProvider services) { _dbFactory = dbFactory; @@ -229,6 +323,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase _worker.TaskUpdatedEvent += taskId => { + if (Task?.Id == taskId) _ = RefreshTagsAndStatusAsync(taskId); if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId); }; @@ -409,6 +504,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase AgentStatusLabel = "Idle"; LatestRunSessionId = null; ShowFailedActions = false; + Tags.Clear(); + AvailableTags.Clear(); + NewTagInput = ""; + _suppressStatusSave = true; + try { SelectedStatus = ClaudeDo.Data.Models.TaskStatus.Idle; } + finally { _suppressStatusSave = false; } _suppressAgentSave = true; try { @@ -436,10 +537,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var subtaskRepo = new SubtaskRepository(ctx); - // Own query with Include so WorktreePath/BranchLine are populated. + // Own query with Include so WorktreePath/BranchLine/Tags are populated. var entity = await ctx.Tasks .AsNoTracking() .Include(t => t.Worktree) + .Include(t => t.Tags) .FirstOrDefaultAsync(t => t.Id == row.Id, ct); ct.ThrowIfCancellationRequested(); if (entity == null) return; @@ -455,6 +557,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase WorktreeStateLabel = entity.Worktree?.State.ToString(); BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null; AgentStatusLabel = entity.Status.ToString(); + _suppressStatusSave = true; + try { SelectedStatus = entity.Status; } + finally { _suppressStatusSave = false; } + ApplyTagsFromEntity(entity); + await RefreshAvailableTagsAsync(); await LoadAgentSettingsAsync(entity, ct); ct.ThrowIfCancellationRequested(); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 3a35f70..b6c9d45 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using ClaudeDo.Data.Models; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -7,6 +8,11 @@ namespace ClaudeDo.Ui.ViewModels.Islands; public sealed partial class TaskRowViewModel : ViewModelBase { + public TaskRowViewModel() + { + Tags.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTags)); + } + public required string Id { get; init; } [ObservableProperty] private string _title = ""; [ObservableProperty] private string _listName = ""; @@ -33,7 +39,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase public DateTime CreatedAt { get; init; } public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}"; - public IReadOnlyList Tags { get; init; } = Array.Empty(); + public ObservableCollection Tags { get; } = new(); public int StepsCount { get; init; } public int StepsCompleted { get; init; } @@ -154,6 +160,15 @@ public sealed partial class TaskRowViewModel : ViewModelBase DiffDeletions = del; ParentTaskId = t.ParentTaskId; BlockedByTaskId = t.BlockedByTaskId; + SetTags(t.Tags.Select(tag => tag.Name)); + } + + public void SetTags(IEnumerable names) + { + var snapshot = names.ToList(); + if (Tags.SequenceEqual(snapshot)) return; + Tags.Clear(); + foreach (var n in snapshot) Tags.Add(n); } // Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions". diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 15830b7..204b277 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -28,6 +28,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase public ObservableCollection OverdueItems { get; } = new(); public ObservableCollection OpenItems { get; } = new(); public ObservableCollection CompletedItems { get; } = new(); + public ObservableCollection AllTags { get; } = new(); [ObservableProperty] private string _newTaskTitle = ""; [ObservableProperty] private TaskRowViewModel? _selectedTask; @@ -54,9 +55,22 @@ public sealed partial class TasksIslandViewModel : ViewModelBase _worker.TaskUpdatedEvent += OnWorkerTaskUpdated; _worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated; _worker.TaskMessageEvent += OnWorkerTaskMessage; + _ = RefreshAllTagsAsync(); } } + private async Task RefreshAllTagsAsync() + { + if (_worker is null) return; + try + { + var tags = await _worker.GetAllTagsAsync(); + AllTags.Clear(); + foreach (var t in tags) AllTags.Add(t); + } + catch { /* offline */ } + } + private void OnWorkerTaskMessage(string taskId, string line) { var row = Items.FirstOrDefault(r => r.Id == taskId); @@ -83,6 +97,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase var entity = await db.Tasks .Include(t => t.List) .Include(t => t.Worktree) + .Include(t => t.Tags) .FirstOrDefaultAsync(t => t.Id == taskId); var existing = Items.FirstOrDefault(r => r.Id == taskId); @@ -171,6 +186,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase var all = await db.Tasks .Include(t => t.List) .Include(t => t.Worktree) + .Include(t => t.Tags) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(ct); @@ -462,6 +478,30 @@ public sealed partial class TasksIslandViewModel : ViewModelBase TasksChanged?.Invoke(this, EventArgs.Empty); } + public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status) + { + if (_worker is null) return; + try { await _worker.SetTaskStatusAsync(row.Id, status); } + catch { /* offline; broadcast won't fire */ } + } + + public async Task ToggleTagOnRowAsync(TaskRowViewModel row, string tagName) + { + if (_worker is null) return; + var name = tagName.Trim().ToLowerInvariant(); + if (name.Length == 0) return; + var current = row.Tags.ToList(); + var next = current.Contains(name) + ? current.Where(t => t != name).ToList() + : current.Append(name).ToList(); + try + { + await _worker.SetTaskTagsAsync(row.Id, next); + await RefreshAllTagsAsync(); + } + catch { } + } + [RelayCommand] private async Task SendToQueueAsync(TaskRowViewModel? row) { diff --git a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml index 08dc8f6..afe499e 100644 --- a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml @@ -37,7 +37,7 @@ - + - + + + + + + + + + + + + + > GetAgentsAsync() => Task.FromResult(new List()); public Task GetListConfigAsync(string listId) => Task.FromResult(null); public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; + public Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status) => Task.CompletedTask; + public Task SetTaskTagsAsync(string taskId, IEnumerable tagNames) => Task.CompletedTask; + public Task> GetAllTagsAsync() => Task.FromResult(new List()); public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs index 13532fb..a729852 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs @@ -67,6 +67,9 @@ public class DetailsIslandPlanningTests : IDisposable public Task> GetAgentsAsync() => Task.FromResult(new List()); public Task GetListConfigAsync(string listId) => Task.FromResult(null); public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; + public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask; + public Task SetTaskTagsAsync(string taskId, IEnumerable tagNames) => Task.CompletedTask; + public Task> GetAllTagsAsync() => Task.FromResult(new List()); public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/PlanningDiffViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/PlanningDiffViewModelTests.cs index 8155328..7160de3 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/PlanningDiffViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/PlanningDiffViewModelTests.cs @@ -34,6 +34,9 @@ public class PlanningDiffViewModelTests public Task> GetAgentsAsync() => Task.FromResult(new List()); public Task GetListConfigAsync(string listId) => Task.FromResult(null); public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; + public Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status) => Task.CompletedTask; + public Task SetTaskTagsAsync(string taskId, IEnumerable tagNames) => Task.CompletedTask; + public Task> GetAllTagsAsync() => Task.FromResult(new List()); public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index a7f1aef..457f5a5 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -37,6 +37,9 @@ sealed class FakeWorkerClient : IWorkerClient public Task> GetAgentsAsync() => Task.FromResult(new List()); public Task GetListConfigAsync(string listId) => Task.FromResult(null); public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; + public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask; + public Task SetTaskTagsAsync(string taskId, IEnumerable tagNames) => Task.CompletedTask; + public Task> GetAllTagsAsync() => Task.FromResult(new List()); public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; } public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; } public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;