feat(ui): agent config inline in detail panel, file picker, subtask UI
TaskDetailView now edits Model / SystemPrompt / Agent inline (LostFocus save), matching the modal editor. Both TaskEditorView and TaskDetailView gain a Browse button that opens a .md file picker — external agent paths are preserved on reload via a synthetic AgentInfo entry. Both views also render the per-task subtask checklist (CheckBox + TextBox + remove), with diff-on-save in the editor and inline-save in the detail panel. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
src/ClaudeDo.Ui/ViewModels/SubtaskItemViewModel.cs
Normal file
23
src/ClaudeDo.Ui/ViewModels/SubtaskItemViewModel.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public partial class SubtaskItemViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _title = string.Empty;
|
||||
[ObservableProperty] private bool _completed;
|
||||
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string? OriginalTitle { get; set; }
|
||||
public bool OriginalCompleted { get; set; }
|
||||
|
||||
public static SubtaskItemViewModel From(SubtaskEntity e) => new()
|
||||
{
|
||||
Id = e.Id,
|
||||
Title = e.Title,
|
||||
Completed = e.Completed,
|
||||
OriginalTitle = e.Title,
|
||||
OriginalCompleted = e.Completed,
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
private readonly GitService _git;
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly TagRepository _tagRepo;
|
||||
private readonly SubtaskRepository _subtaskRepo;
|
||||
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string? _description;
|
||||
@@ -28,9 +29,14 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _statusText = "";
|
||||
[ObservableProperty] private string _statusChoice = "Manual";
|
||||
[ObservableProperty] private string _commitType = "chore";
|
||||
[ObservableProperty] private string _modelChoice = "(list default)";
|
||||
[ObservableProperty] private string? _systemPromptOverride;
|
||||
[ObservableProperty] private AgentInfo? _selectedAgent;
|
||||
public List<AgentInfo> AvailableAgents { get; } = [];
|
||||
|
||||
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"];
|
||||
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
|
||||
|
||||
// Worktree
|
||||
[ObservableProperty] private bool _hasWorktree;
|
||||
@@ -44,6 +50,7 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
private StreamLineFormatter _formatter = new();
|
||||
public ObservableCollection<TagEntity> Tags { get; } = new();
|
||||
[ObservableProperty] private string _newTagInput = "";
|
||||
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
|
||||
|
||||
private string? _taskId;
|
||||
private string? _listId;
|
||||
@@ -52,7 +59,8 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
public event Action<string>? TaskChanged;
|
||||
|
||||
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo,
|
||||
ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo)
|
||||
ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo,
|
||||
SubtaskRepository subtaskRepo)
|
||||
{
|
||||
_taskRepo = taskRepo;
|
||||
_worktreeRepo = worktreeRepo;
|
||||
@@ -60,6 +68,7 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
_git = git;
|
||||
_worker = worker;
|
||||
_tagRepo = tagRepo;
|
||||
_subtaskRepo = subtaskRepo;
|
||||
|
||||
worker.TaskMessageEvent += OnTaskMessage;
|
||||
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
|
||||
@@ -77,6 +86,13 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
var task = await _taskRepo.GetByIdAsync(taskId);
|
||||
if (task is null) return;
|
||||
|
||||
if (AvailableAgents.Count == 0)
|
||||
{
|
||||
var agents = await _worker.GetAgentsAsync();
|
||||
AvailableAgents.AddRange(agents);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
@@ -95,11 +111,39 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
StatusText = task.Status.ToString().ToLowerInvariant();
|
||||
StatusChoice = task.Status.ToString();
|
||||
CommitType = task.CommitType;
|
||||
ModelChoice = task.Model is not null
|
||||
? ListEditorViewModel.ModelIdToDisplay(task.Model)
|
||||
: "(list default)";
|
||||
SystemPromptOverride = task.SystemPrompt;
|
||||
if (task.AgentPath is not null)
|
||||
{
|
||||
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath);
|
||||
if (match is null)
|
||||
{
|
||||
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath);
|
||||
AvailableAgents.Add(match);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
SelectedAgent = match;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedAgent = null;
|
||||
}
|
||||
|
||||
Tags.Clear();
|
||||
var tags = await _taskRepo.GetTagsAsync(taskId);
|
||||
foreach (var tag in tags)
|
||||
Tags.Add(tag);
|
||||
|
||||
Subtasks.Clear();
|
||||
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId);
|
||||
foreach (var s in subtasks)
|
||||
{
|
||||
var vm = SubtaskItemViewModel.From(s);
|
||||
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
||||
Subtasks.Add(vm);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -119,6 +163,11 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
entity.Title = Title;
|
||||
entity.Description = Description;
|
||||
entity.CommitType = CommitType;
|
||||
entity.Model = ModelChoice != "(list default)"
|
||||
? ListEditorViewModel.ModelDisplayToId(ModelChoice)
|
||||
: null;
|
||||
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
|
||||
entity.AgentPath = SelectedAgent?.Path;
|
||||
|
||||
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
|
||||
entity.Status = status;
|
||||
@@ -155,6 +204,61 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
TaskChanged?.Invoke(_taskId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddSubtask()
|
||||
{
|
||||
if (_taskId is null) return;
|
||||
var entity = new SubtaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
TaskId = _taskId,
|
||||
Title = "",
|
||||
Completed = false,
|
||||
OrderNum = Subtasks.Count,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _subtaskRepo.AddAsync(entity);
|
||||
var vm = SubtaskItemViewModel.From(entity);
|
||||
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
||||
Subtasks.Add(vm);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RemoveSubtask(SubtaskItemViewModel item)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(item.Id))
|
||||
await _subtaskRepo.DeleteAsync(item.Id);
|
||||
item.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||
Subtasks.Remove(item);
|
||||
}
|
||||
|
||||
private async void OnSubtaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return;
|
||||
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
|
||||
await _subtaskRepo.UpdateAsync(new SubtaskEntity
|
||||
{
|
||||
Id = vm.Id,
|
||||
TaskId = _taskId ?? "",
|
||||
Title = vm.Title,
|
||||
Completed = vm.Completed,
|
||||
OrderNum = Subtasks.IndexOf(vm),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
public void SetAgentFromPath(string path)
|
||||
{
|
||||
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
|
||||
AvailableAgents.Add(existing);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
SelectedAgent = existing;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_taskId = null;
|
||||
@@ -169,8 +273,13 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
_formatter = new StreamLineFormatter();
|
||||
Tags.Clear();
|
||||
NewTagInput = "";
|
||||
foreach (var s in Subtasks) s.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||
Subtasks.Clear();
|
||||
StatusChoice = "Manual";
|
||||
CommitType = "chore";
|
||||
ModelChoice = "(list default)";
|
||||
SystemPromptOverride = null;
|
||||
SelectedAgent = null;
|
||||
}
|
||||
|
||||
private async Task LoadWorktreeAsync(string taskId)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -8,6 +11,8 @@ namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public partial class TaskEditorViewModel : ViewModelBase
|
||||
{
|
||||
private readonly SubtaskRepository _subtaskRepo;
|
||||
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string? _description;
|
||||
[ObservableProperty] private string _commitType = "chore";
|
||||
@@ -18,6 +23,7 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
[ObservableProperty] private string? _systemPromptOverride;
|
||||
[ObservableProperty] private AgentInfo? _selectedAgent;
|
||||
public List<AgentInfo> AvailableAgents { get; set; } = [];
|
||||
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
|
||||
|
||||
private string? _editId;
|
||||
private string _listId = "";
|
||||
@@ -34,11 +40,28 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
public static string[] StatusChoices { get; } =
|
||||
["manual", "queued"];
|
||||
|
||||
public TaskEditorViewModel(SubtaskRepository subtaskRepo)
|
||||
{
|
||||
_subtaskRepo = subtaskRepo;
|
||||
}
|
||||
|
||||
public async Task LoadAgentsAsync(WorkerClient worker)
|
||||
{
|
||||
AvailableAgents = await worker.GetAgentsAsync();
|
||||
}
|
||||
|
||||
public void SetAgentFromPath(string path)
|
||||
{
|
||||
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
|
||||
AvailableAgents.Add(existing);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
SelectedAgent = existing;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> SelectedTagNames =>
|
||||
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct()
|
||||
@@ -51,8 +74,54 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
_createdAt = DateTime.UtcNow;
|
||||
CommitType = defaultCommitType;
|
||||
WindowTitle = "New Task";
|
||||
Subtasks.Clear();
|
||||
}
|
||||
|
||||
public async Task InitForEditAsync(TaskEntity entity, IReadOnlyList<TagEntity> taskTags, CancellationToken ct = default)
|
||||
{
|
||||
_editId = entity.Id;
|
||||
_listId = entity.ListId;
|
||||
_createdAt = entity.CreatedAt;
|
||||
Title = entity.Title;
|
||||
Description = entity.Description;
|
||||
CommitType = entity.CommitType;
|
||||
StatusChoice = entity.Status switch
|
||||
{
|
||||
TaskStatus.Manual => "manual",
|
||||
TaskStatus.Queued => "queued",
|
||||
_ => entity.Status.ToString().ToLowerInvariant(),
|
||||
};
|
||||
TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
|
||||
ModelChoice = entity.Model is not null
|
||||
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
|
||||
: "(list default)";
|
||||
SystemPromptOverride = entity.SystemPrompt;
|
||||
|
||||
if (entity.AgentPath is not null)
|
||||
{
|
||||
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
|
||||
if (match is null)
|
||||
{
|
||||
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
|
||||
AvailableAgents.Add(match);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
SelectedAgent = match;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedAgent = null;
|
||||
}
|
||||
|
||||
WindowTitle = $"Edit Task: {entity.Title}";
|
||||
|
||||
Subtasks.Clear();
|
||||
var list = await _subtaskRepo.GetByTaskIdAsync(entity.Id, ct);
|
||||
foreach (var s in list)
|
||||
Subtasks.Add(SubtaskItemViewModel.From(s));
|
||||
}
|
||||
|
||||
// Keep old sync overload for callers that haven't loaded agents yet
|
||||
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
|
||||
{
|
||||
_editId = entity.Id;
|
||||
@@ -72,14 +141,34 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
|
||||
: "(list default)";
|
||||
SystemPromptOverride = entity.SystemPrompt;
|
||||
SelectedAgent = entity.AgentPath is not null
|
||||
? AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath)
|
||||
: null;
|
||||
|
||||
if (entity.AgentPath is not null)
|
||||
{
|
||||
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
|
||||
if (match is null)
|
||||
{
|
||||
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
|
||||
AvailableAgents.Add(match);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
SelectedAgent = match;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedAgent = null;
|
||||
}
|
||||
|
||||
WindowTitle = $"Edit Task: {entity.Title}";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
private void AddSubtask() => Subtasks.Add(new SubtaskItemViewModel());
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveSubtask(SubtaskItemViewModel item) => Subtasks.Remove(item);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Save()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Title)) return;
|
||||
var status = StatusChoice switch
|
||||
@@ -87,9 +176,10 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
"queued" => TaskStatus.Queued,
|
||||
_ => TaskStatus.Manual,
|
||||
};
|
||||
var taskId = _editId ?? Guid.NewGuid().ToString();
|
||||
var entity = new TaskEntity
|
||||
{
|
||||
Id = _editId ?? Guid.NewGuid().ToString(),
|
||||
Id = taskId,
|
||||
ListId = _listId,
|
||||
Title = Title.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
|
||||
@@ -102,6 +192,42 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
: null;
|
||||
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
|
||||
entity.AgentPath = SelectedAgent?.Path;
|
||||
|
||||
// Persist subtask changes
|
||||
if (_editId is not null)
|
||||
{
|
||||
var existing = await _subtaskRepo.GetByTaskIdAsync(taskId);
|
||||
var existingIds = existing.Select(s => s.Id).ToHashSet();
|
||||
var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet();
|
||||
|
||||
// Deleted
|
||||
foreach (var id in existingIds.Except(currentIds))
|
||||
await _subtaskRepo.DeleteAsync(id);
|
||||
|
||||
// Updated
|
||||
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)))
|
||||
{
|
||||
if (vm.Id == "") continue;
|
||||
if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted)
|
||||
await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
|
||||
else
|
||||
{
|
||||
// update order_num if position changed
|
||||
var orig = existing.FirstOrDefault(e => e.Id == vm.Id);
|
||||
if (orig is not null && orig.OrderNum != idx)
|
||||
await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Added (id == "" means new)
|
||||
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == ""))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vm.Title)) continue;
|
||||
var newId = Guid.NewGuid().ToString();
|
||||
await _subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
_tcs.TrySetResult(entity);
|
||||
RequestClose?.Invoke();
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ public partial class TaskListViewModel : ViewModelBase
|
||||
var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
|
||||
var editor = _editorFactory();
|
||||
await editor.LoadAgentsAsync(_worker);
|
||||
editor.InitForEdit(entity, taskTags);
|
||||
await editor.InitForEditAsync(entity, taskTags);
|
||||
|
||||
var window = new TaskEditorView { DataContext = editor };
|
||||
editor.RequestClose += () => window.Close();
|
||||
|
||||
Reference in New Issue
Block a user