refactor(tags): remove tag entity and all references
Drops TagEntity, TagRepository, and tag wiring across data layer, worker, and UI. Adds RemoveTags migration to clean up schema. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,8 +32,6 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||
Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames);
|
||||
Task<List<string>> GetAllTagsAsync();
|
||||
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
||||
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
|
||||
@@ -395,23 +395,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
||||
}
|
||||
|
||||
public async Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames)
|
||||
{
|
||||
await _hub.InvokeAsync("SetTaskTags", taskId, tagNames.ToArray());
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAllTagsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<List<string>>("GetAllTags") ?? new List<string>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -21,7 +21,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
|
||||
// Current task row (set by IslandsShellViewModel via Bind)
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
|
||||
private TaskRowViewModel? _task;
|
||||
|
||||
// Editable fields
|
||||
@@ -56,74 +58,23 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
// Short task-id badge, e.g. "#T1A"
|
||||
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";
|
||||
public bool IsRunning => AgentStatusLabel == "Running";
|
||||
public bool IsDone => AgentStatusLabel == "Done";
|
||||
public bool IsFailed => AgentStatusLabel == "Failed";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ResetCommand))]
|
||||
private bool _showFailedActions;
|
||||
private string _agentStatusLabel = "Idle";
|
||||
public bool IsIdle => AgentStatusLabel == "Idle";
|
||||
public bool IsQueued => AgentStatusLabel == "Queued";
|
||||
public bool IsRunning => AgentStatusLabel == "Running";
|
||||
public bool IsDone => AgentStatusLabel == "Done";
|
||||
public bool IsFailed => AgentStatusLabel == "Failed";
|
||||
public bool IsCancelled => AgentStatusLabel == "Cancelled";
|
||||
|
||||
// Recovery actions: Continue (resume session) for Failed/Cancelled.
|
||||
public bool ShowContinue => IsFailed || IsCancelled;
|
||||
// Reset & retry available from any terminal state.
|
||||
public bool ShowResetAndRetry => IsFailed || IsCancelled || IsDone;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||
@@ -131,11 +82,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
|
||||
partial void OnAgentStatusLabelChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsIdle));
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsFailed));
|
||||
OnPropertyChanged(nameof(IsCancelled));
|
||||
OnPropertyChanged(nameof(ShowContinue));
|
||||
OnPropertyChanged(nameof(ShowResetAndRetry));
|
||||
OnPropertyChanged(nameof(IsAgentSectionEnabled));
|
||||
ShowFailedActions = value == "Failed";
|
||||
}
|
||||
[ObservableProperty] private string? _model;
|
||||
|
||||
@@ -237,40 +192,17 @@ 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)
|
||||
private async System.Threading.Tasks.Task RefreshStatusAsync(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 { }
|
||||
}
|
||||
@@ -289,9 +221,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
{
|
||||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||||
{
|
||||
RunNowCommand.NotifyCanExecuteChanged();
|
||||
EnqueueCommand.NotifyCanExecuteChanged();
|
||||
DequeueCommand.NotifyCanExecuteChanged();
|
||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||
ContinueCommand.NotifyCanExecuteChanged();
|
||||
ResetCommand.NotifyCanExecuteChanged();
|
||||
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
};
|
||||
@@ -323,7 +256,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
|
||||
_worker.TaskUpdatedEvent += taskId =>
|
||||
{
|
||||
if (Task?.Id == taskId) _ = RefreshTagsAndStatusAsync(taskId);
|
||||
if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId);
|
||||
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||||
};
|
||||
|
||||
@@ -503,13 +436,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
BranchLine = null;
|
||||
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
|
||||
{
|
||||
@@ -537,11 +463,10 @@ 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/Tags are populated.
|
||||
// Own query with Include so WorktreePath/BranchLine 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;
|
||||
@@ -557,11 +482,6 @@ 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();
|
||||
|
||||
@@ -926,24 +846,35 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
await _worker.CancelTaskAsync(Task.Id);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRunNow))]
|
||||
private async System.Threading.Tasks.Task RunNowAsync()
|
||||
[RelayCommand(CanExecute = nameof(CanEnqueue))]
|
||||
private async System.Threading.Tasks.Task EnqueueAsync()
|
||||
{
|
||||
if (Task == null) return;
|
||||
AgentStatusLabel = "Running";
|
||||
try
|
||||
{
|
||||
await _worker.RunNowAsync(Task.Id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
AgentStatusLabel = "Failed";
|
||||
throw;
|
||||
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||
AgentStatusLabel = "Queued";
|
||||
}
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
private bool CanRunNow() =>
|
||||
Task != null && _worker.IsConnected && !IsRunning;
|
||||
private bool CanEnqueue() =>
|
||||
Task != null && _worker.IsConnected && IsIdle;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanDequeue))]
|
||||
private async System.Threading.Tasks.Task DequeueAsync()
|
||||
{
|
||||
if (Task == null) return;
|
||||
try
|
||||
{
|
||||
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Idle);
|
||||
AgentStatusLabel = "Idle";
|
||||
}
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
private bool CanDequeue() =>
|
||||
Task != null && _worker.IsConnected && IsQueued;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanContinue))]
|
||||
private async System.Threading.Tasks.Task ContinueAsync()
|
||||
@@ -953,23 +884,32 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
private bool CanContinue() =>
|
||||
Task != null && _worker.IsConnected && ShowFailedActions && !string.IsNullOrEmpty(LatestRunSessionId);
|
||||
Task != null && _worker.IsConnected && ShowContinue && !string.IsNullOrEmpty(LatestRunSessionId);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanReset))]
|
||||
private async System.Threading.Tasks.Task ResetAsync()
|
||||
[RelayCommand(CanExecute = nameof(CanResetAndRetry))]
|
||||
private async System.Threading.Tasks.Task ResetAndRetryAsync()
|
||||
{
|
||||
if (Task == null) return;
|
||||
if (ConfirmAsync == null) return;
|
||||
|
||||
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
||||
var ok = await ConfirmAsync($"Discard worktree and reset task?\nThis deletes branch {branchName} and all uncommitted changes.");
|
||||
var ok = await ConfirmAsync(
|
||||
$"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning.");
|
||||
if (!ok) return;
|
||||
|
||||
await _worker.ResetTaskAsync(Task.Id);
|
||||
if (WorktreePath != null)
|
||||
await _worker.ResetTaskAsync(Task.Id);
|
||||
|
||||
try
|
||||
{
|
||||
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||
AgentStatusLabel = "Queued";
|
||||
}
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
private bool CanReset() =>
|
||||
Task != null && _worker.IsConnected && ShowFailedActions;
|
||||
private bool CanResetAndRetry() =>
|
||||
Task != null && _worker.IsConnected && ShowResetAndRetry;
|
||||
}
|
||||
|
||||
public sealed partial class SubtaskRowViewModel : ViewModelBase
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
@@ -8,11 +6,6 @@ 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 = "";
|
||||
@@ -39,7 +32,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
||||
|
||||
public ObservableCollection<string> Tags { get; } = new();
|
||||
public int StepsCount { get; init; }
|
||||
public int StepsCompleted { get; init; }
|
||||
|
||||
@@ -62,13 +54,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
|
||||
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
|
||||
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
|
||||
public bool HasTags => Tags.Count > 0;
|
||||
public bool HasSteps => StepsCount > 0;
|
||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||
public bool IsRunning => Status == TaskStatus.Running;
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks;
|
||||
public bool HasSchedule => ScheduledFor.HasValue;
|
||||
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||
|
||||
@@ -96,6 +88,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
}
|
||||
|
||||
partial void OnPlanningPhaseChanged(PlanningPhase value)
|
||||
@@ -107,7 +100,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
partial void OnHasQueuedSubtasksChanged(bool value)
|
||||
=> OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
{
|
||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
}
|
||||
|
||||
partial void OnBlockedByTaskIdChanged(string? value)
|
||||
{
|
||||
@@ -160,15 +156,6 @@ 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".
|
||||
|
||||
@@ -31,7 +31,6 @@ 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;
|
||||
@@ -59,22 +58,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
||||
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
|
||||
_ = 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);
|
||||
@@ -101,7 +87,6 @@ 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);
|
||||
@@ -190,7 +175,6 @@ 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);
|
||||
|
||||
@@ -484,37 +468,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
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)
|
||||
{
|
||||
if (row is null || row.IsRunning) return;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity is null) return;
|
||||
entity.Status = TaskStatus.Queued;
|
||||
// Worker queue picker requires the "agent" tag — attach it on explicit enqueue.
|
||||
if (!entity.Tags.Any(t => t.Name == "agent"))
|
||||
{
|
||||
var agentTag = await db.Tags.FirstOrDefaultAsync(t => t.Name == "agent");
|
||||
if (agentTag is not null) entity.Tags.Add(agentTag);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
row.Status = TaskStatus.Queued;
|
||||
if (_worker is not null)
|
||||
@@ -568,6 +529,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CancelRunningTaskAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || !row.IsRunning || _worker is null) return;
|
||||
try { await _worker.CancelTaskAsync(row.Id); }
|
||||
catch { /* worker offline; the broadcast will reconcile when it returns */ }
|
||||
}
|
||||
|
||||
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
|
||||
{
|
||||
if (row is null) return;
|
||||
|
||||
@@ -42,13 +42,22 @@
|
||||
<PathIcon Data="{StaticResource Icon.X}" Width="12" Height="12"
|
||||
Foreground="{DynamicResource BloodBrush}"/>
|
||||
</Button>
|
||||
<!-- Hand off button — only when idle -->
|
||||
<!-- Send to queue — only when idle -->
|
||||
<Button Grid.Column="3"
|
||||
Classes="btn accent"
|
||||
Content="Hand off"
|
||||
Command="{Binding RunNowCommand}"
|
||||
IsVisible="{Binding !IsRunning}"
|
||||
ToolTip.Tip="Hand task off to Claude"
|
||||
Content="Send to queue"
|
||||
Command="{Binding EnqueueCommand}"
|
||||
IsVisible="{Binding IsIdle}"
|
||||
ToolTip.Tip="Queue this task for the worker to pick up"
|
||||
VerticalAlignment="Center"
|
||||
Padding="10,4"/>
|
||||
<!-- Remove from queue — only when queued -->
|
||||
<Button Grid.Column="3"
|
||||
Classes="btn"
|
||||
Content="Remove from queue"
|
||||
Command="{Binding DequeueCommand}"
|
||||
IsVisible="{Binding IsQueued}"
|
||||
ToolTip.Tip="Take this task back out of the queue"
|
||||
VerticalAlignment="Center"
|
||||
Padding="10,4"/>
|
||||
</Grid>
|
||||
@@ -144,14 +153,14 @@
|
||||
<Button Classes="btn accent"
|
||||
Content="Continue"
|
||||
Command="{Binding ContinueCommand}"
|
||||
IsVisible="{Binding ShowFailedActions}"
|
||||
ToolTip.Tip="Resume the task from where it failed"
|
||||
IsVisible="{Binding ShowContinue}"
|
||||
ToolTip.Tip="Resume the last session and keep going"
|
||||
Padding="10,4"/>
|
||||
<Button Classes="btn"
|
||||
Content="Reset"
|
||||
Command="{Binding ResetCommand}"
|
||||
IsVisible="{Binding ShowFailedActions}"
|
||||
ToolTip.Tip="Discard the worktree and move the task back to Manual"
|
||||
Content="Reset & retry"
|
||||
Command="{Binding ResetAndRetryCommand}"
|
||||
IsVisible="{Binding ShowResetAndRetry}"
|
||||
ToolTip.Tip="Discard the worktree and re-queue the task to run from scratch"
|
||||
Padding="10,4"/>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -35,36 +35,39 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── Header (sticky top): eyebrow · title · gear (agent-settings flyout) ── -->
|
||||
<!-- ── Header (sticky top): check · eyebrow · title · status · star · gear ── -->
|
||||
<Border DockPanel.Dock="Top" Classes="island-header">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4">
|
||||
<Ellipse Width="5" Height="5" Fill="{DynamicResource AccentBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="eyebrow" Text="LOGBOOK" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding TaskIdBadge}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
Foreground="{DynamicResource TextFaintBrush}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<Ellipse Grid.Column="0"
|
||||
Classes="task-check"
|
||||
Classes.done="{Binding Task.Done}"
|
||||
Width="18" Height="18"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,2,10,0"
|
||||
Cursor="Hand"/>
|
||||
<StackPanel Grid.Column="1" Spacing="0">
|
||||
<TextBlock Text="{Binding TaskIdBadge}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
Foreground="{DynamicResource TextFaintBrush}"
|
||||
Margin="0,0,0,4"/>
|
||||
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
|
||||
FontSize="14" FontWeight="Medium"
|
||||
BorderThickness="0" Background="Transparent"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextWrapping="Wrap"
|
||||
AcceptsReturn="False"
|
||||
Padding="0"/>
|
||||
</StackPanel>
|
||||
|
||||
<ComboBox Grid.Column="1"
|
||||
ItemsSource="{Binding StatusOptions}"
|
||||
SelectedItem="{Binding SelectedStatus, Mode=TwoWay}"
|
||||
ToolTip.Tip="Set status (no transition guards)"
|
||||
VerticalAlignment="Top"
|
||||
MinWidth="110"
|
||||
Margin="6,0,0,0"/>
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-btn star-btn"
|
||||
Classes.on="{Binding Task.IsStarred}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
<Button Grid.Column="3" Classes="icon-btn"
|
||||
ToolTip.Tip="Agent settings"
|
||||
IsEnabled="{Binding IsAgentSectionEnabled}"
|
||||
VerticalAlignment="Top"
|
||||
@@ -112,34 +115,6 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── Task strip row (sticky top): check + title + star ── -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
Padding="18,10,18,10"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Ellipse Grid.Column="0"
|
||||
Classes="task-check"
|
||||
Classes.done="{Binding Task.Done}"
|
||||
Width="18" Height="18"
|
||||
VerticalAlignment="Center"
|
||||
Cursor="Hand"/>
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding EditableTitle}"
|
||||
FontSize="14" FontWeight="Medium"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center"
|
||||
Margin="10,0"/>
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-btn star-btn"
|
||||
Classes.on="{Binding Task.IsStarred}"
|
||||
VerticalAlignment="Center">
|
||||
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── Agent status strip (sticky, above metadata footer) ── -->
|
||||
<islands:AgentStripView DockPanel.Dock="Bottom"/>
|
||||
|
||||
@@ -147,46 +122,6 @@
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="0">
|
||||
|
||||
<!-- Tags section -->
|
||||
<Border Padding="18,12,18,12"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="TAGS" Margin="0,0,0,2"/>
|
||||
<ItemsControl ItemsSource="{Binding Tags}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<Border Classes="chip chip-tag" Margin="0,0,6,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding}" VerticalAlignment="Center"/>
|
||||
<Button Classes="icon-btn"
|
||||
Padding="2,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="Remove tag"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).RemoveTagCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Text="×" FontSize="12"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<AutoCompleteBox ItemsSource="{Binding AvailableTags}"
|
||||
Text="{Binding NewTagInput, Mode=TwoWay}"
|
||||
Watermark="Add tag (Enter to add)">
|
||||
<AutoCompleteBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}"/>
|
||||
</AutoCompleteBox.KeyBindings>
|
||||
</AutoCompleteBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Planning merge section — visible only for planning parent tasks -->
|
||||
<Border Padding="18,12,18,12"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
|
||||
@@ -31,23 +31,21 @@
|
||||
Classes.selected="{Binding IsSelected}"
|
||||
Classes.done="{Binding Done}">
|
||||
<Border.ContextMenu>
|
||||
<ContextMenu Opening="OnContextMenuOpening">
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Send to queue"
|
||||
IsVisible="{Binding !IsQueued}"
|
||||
IsVisible="{Binding CanSendToQueue}"
|
||||
Click="OnSendToQueueClick"/>
|
||||
<MenuItem Header="Remove from queue"
|
||||
IsVisible="{Binding CanRemoveFromQueue}"
|
||||
Click="OnRemoveFromQueueClick"/>
|
||||
<MenuItem Header="Cancel execution"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
Click="OnCancelExecutionClick"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Set status">
|
||||
<MenuItem Header="Idle" Tag="Idle" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Queued" Tag="Queued" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Running" Tag="Running" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Mark as">
|
||||
<MenuItem Header="Done" Tag="Done" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Failed" Tag="Failed" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Cancelled" Tag="Cancelled" Click="OnSetStatusClick"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Tags" x:Name="TagsMenu"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Run interactively"
|
||||
Click="OnRunInteractivelyClick"/>
|
||||
@@ -99,16 +97,19 @@
|
||||
|
||||
<!-- Title + chip row + live tail -->
|
||||
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||
<TextBlock Classes="task-title"
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="task-title"
|
||||
Text="{Binding Title}" FontSize="14"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextWrapping="Wrap"
|
||||
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
|
||||
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
|
||||
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
|
||||
|
||||
<!-- Badges: DRAFT and planning session -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
|
||||
VerticalAlignment="Center" Margin="4,0,0,0">
|
||||
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
||||
<TextBlock Text="DRAFT"/>
|
||||
</Border>
|
||||
@@ -116,7 +117,7 @@
|
||||
<TextBlock Text="{Binding PlanningBadge}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Chip row -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
@@ -167,21 +168,6 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Tag chips -->
|
||||
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Classes="chip chip-tag">
|
||||
<TextBlock Text="{Binding}"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Live-tail row (visible when running + has tail) -->
|
||||
|
||||
@@ -36,6 +36,12 @@ public partial class TaskRowView : UserControl
|
||||
await vm.RemoveFromQueueCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnCancelExecutionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
await vm.CancelRunningTaskCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnClearScheduleClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
@@ -82,37 +88,6 @@ public partial class TaskRowView : UserControl
|
||||
await vm.SetStatusOnRowAsync(row, status);
|
||||
}
|
||||
|
||||
private void OnContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
if (DataContext is not TaskRowViewModel row || FindTasksVm() is not { } vm) return;
|
||||
|
||||
// Build the union of all known tags + tags currently on this row, so a row's
|
||||
// own tags stay reachable from the menu even if the global list is stale.
|
||||
var rowTags = row.Tags.ToHashSet();
|
||||
var union = vm.AllTags.Concat(rowTags).Distinct().OrderBy(t => t).ToList();
|
||||
|
||||
TagsMenu.Items.Clear();
|
||||
if (union.Count == 0)
|
||||
{
|
||||
TagsMenu.Items.Add(new MenuItem { Header = "(no tags yet)", IsEnabled = false });
|
||||
return;
|
||||
}
|
||||
foreach (var name in union)
|
||||
{
|
||||
var prefix = rowTags.Contains(name) ? "✓ " : " ";
|
||||
var item = new MenuItem { Header = prefix + name, Tag = name };
|
||||
item.Click += OnToggleTagClick;
|
||||
TagsMenu.Items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnToggleTagClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not MenuItem mi || mi.Tag is not string name) return;
|
||||
if (DataContext is not TaskRowViewModel row || FindTasksVm() is not { } vm) return;
|
||||
await vm.ToggleTagOnRowAsync(row, name);
|
||||
}
|
||||
|
||||
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not TaskRowViewModel row) return;
|
||||
|
||||
@@ -151,10 +151,10 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*" MinWidth="320"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="320" MinWidth="280"/>
|
||||
<ColumnDefinition Width="460" MinWidth="280"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Classes="island" Margin="7">
|
||||
<Border Grid.Column="0" Classes="island" Margin="3">
|
||||
<islands:ListsIslandView DataContext="{Binding Lists}"/>
|
||||
</Border>
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
ResizeDirection="Columns"
|
||||
ResizeBehavior="PreviousAndNext"/>
|
||||
|
||||
<Border Grid.Column="2" Classes="island" Margin="7">
|
||||
<Border Grid.Column="2" Classes="island" Margin="3">
|
||||
<islands:TasksIslandView DataContext="{Binding Tasks}"/>
|
||||
</Border>
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
ResizeBehavior="PreviousAndNext"
|
||||
IsVisible="{Binding ShowDetails}"/>
|
||||
|
||||
<Border Grid.Column="4" Classes="island" Margin="7"
|
||||
<Border Grid.Column="4" Classes="island" Margin="3"
|
||||
IsVisible="{Binding ShowDetails}">
|
||||
<islands:DetailsIslandView DataContext="{Binding Details}"/>
|
||||
</Border>
|
||||
|
||||
Reference in New Issue
Block a user