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:
Mika Kuns
2026-04-15 11:20:17 +02:00
parent 8c051d8f62
commit 9a407bde83
8 changed files with 410 additions and 16 deletions

View 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,
};
}

View File

@@ -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)

View File

@@ -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();
}

View File

@@ -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();