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) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-29 10:40:03 +02:00
parent 47b07373af
commit c1856657b5
10 changed files with 252 additions and 4 deletions

View File

@@ -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<ClaudeDo.Data.Models.TaskStatus> 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<string> Tags { get; } = new();
public ObservableCollection<string> 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<string, System.Threading.Tasks.Task>? 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<ClaudeDoDbContext> 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();

View File

@@ -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<string> Tags { get; init; } = Array.Empty<string>();
public ObservableCollection<string> 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<string> 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".

View File

@@ -28,6 +28,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
public ObservableCollection<string> 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)
{