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 GitService _git;
|
||||||
private readonly WorkerClient _worker;
|
private readonly WorkerClient _worker;
|
||||||
private readonly TagRepository _tagRepo;
|
private readonly TagRepository _tagRepo;
|
||||||
|
private readonly SubtaskRepository _subtaskRepo;
|
||||||
|
|
||||||
[ObservableProperty] private string _title = "";
|
[ObservableProperty] private string _title = "";
|
||||||
[ObservableProperty] private string? _description;
|
[ObservableProperty] private string? _description;
|
||||||
@@ -28,9 +29,14 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string _statusText = "";
|
[ObservableProperty] private string _statusText = "";
|
||||||
[ObservableProperty] private string _statusChoice = "Manual";
|
[ObservableProperty] private string _statusChoice = "Manual";
|
||||||
[ObservableProperty] private string _commitType = "chore";
|
[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[] 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[] CommitTypes { get; } = ["feat", "fix", "refactor", "docs", "test", "chore", "ci", "perf", "style", "build"];
|
||||||
|
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
|
||||||
|
|
||||||
// Worktree
|
// Worktree
|
||||||
[ObservableProperty] private bool _hasWorktree;
|
[ObservableProperty] private bool _hasWorktree;
|
||||||
@@ -44,6 +50,7 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
private StreamLineFormatter _formatter = new();
|
private StreamLineFormatter _formatter = new();
|
||||||
public ObservableCollection<TagEntity> Tags { get; } = new();
|
public ObservableCollection<TagEntity> Tags { get; } = new();
|
||||||
[ObservableProperty] private string _newTagInput = "";
|
[ObservableProperty] private string _newTagInput = "";
|
||||||
|
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
|
||||||
|
|
||||||
private string? _taskId;
|
private string? _taskId;
|
||||||
private string? _listId;
|
private string? _listId;
|
||||||
@@ -52,7 +59,8 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
public event Action<string>? TaskChanged;
|
public event Action<string>? TaskChanged;
|
||||||
|
|
||||||
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo,
|
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;
|
_taskRepo = taskRepo;
|
||||||
_worktreeRepo = worktreeRepo;
|
_worktreeRepo = worktreeRepo;
|
||||||
@@ -60,6 +68,7 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
_git = git;
|
_git = git;
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_tagRepo = tagRepo;
|
_tagRepo = tagRepo;
|
||||||
|
_subtaskRepo = subtaskRepo;
|
||||||
|
|
||||||
worker.TaskMessageEvent += OnTaskMessage;
|
worker.TaskMessageEvent += OnTaskMessage;
|
||||||
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
|
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
|
||||||
@@ -77,6 +86,13 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
var task = await _taskRepo.GetByIdAsync(taskId);
|
var task = await _taskRepo.GetByIdAsync(taskId);
|
||||||
if (task is null) return;
|
if (task is null) return;
|
||||||
|
|
||||||
|
if (AvailableAgents.Count == 0)
|
||||||
|
{
|
||||||
|
var agents = await _worker.GetAgentsAsync();
|
||||||
|
AvailableAgents.AddRange(agents);
|
||||||
|
OnPropertyChanged(nameof(AvailableAgents));
|
||||||
|
}
|
||||||
|
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -95,11 +111,39 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
StatusText = task.Status.ToString().ToLowerInvariant();
|
StatusText = task.Status.ToString().ToLowerInvariant();
|
||||||
StatusChoice = task.Status.ToString();
|
StatusChoice = task.Status.ToString();
|
||||||
CommitType = task.CommitType;
|
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();
|
Tags.Clear();
|
||||||
var tags = await _taskRepo.GetTagsAsync(taskId);
|
var tags = await _taskRepo.GetTagsAsync(taskId);
|
||||||
foreach (var tag in tags)
|
foreach (var tag in tags)
|
||||||
Tags.Add(tag);
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -119,6 +163,11 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
entity.Title = Title;
|
entity.Title = Title;
|
||||||
entity.Description = Description;
|
entity.Description = Description;
|
||||||
entity.CommitType = CommitType;
|
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))
|
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
|
||||||
entity.Status = status;
|
entity.Status = status;
|
||||||
@@ -155,6 +204,61 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
TaskChanged?.Invoke(_taskId);
|
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()
|
public void Clear()
|
||||||
{
|
{
|
||||||
_taskId = null;
|
_taskId = null;
|
||||||
@@ -169,8 +273,13 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
_formatter = new StreamLineFormatter();
|
_formatter = new StreamLineFormatter();
|
||||||
Tags.Clear();
|
Tags.Clear();
|
||||||
NewTagInput = "";
|
NewTagInput = "";
|
||||||
|
foreach (var s in Subtasks) s.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||||
|
Subtasks.Clear();
|
||||||
StatusChoice = "Manual";
|
StatusChoice = "Manual";
|
||||||
CommitType = "chore";
|
CommitType = "chore";
|
||||||
|
ModelChoice = "(list default)";
|
||||||
|
SystemPromptOverride = null;
|
||||||
|
SelectedAgent = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadWorktreeAsync(string taskId)
|
private async Task LoadWorktreeAsync(string taskId)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
@@ -8,6 +11,8 @@ namespace ClaudeDo.Ui.ViewModels;
|
|||||||
|
|
||||||
public partial class TaskEditorViewModel : ViewModelBase
|
public partial class TaskEditorViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
|
private readonly SubtaskRepository _subtaskRepo;
|
||||||
|
|
||||||
[ObservableProperty] private string _title = "";
|
[ObservableProperty] private string _title = "";
|
||||||
[ObservableProperty] private string? _description;
|
[ObservableProperty] private string? _description;
|
||||||
[ObservableProperty] private string _commitType = "chore";
|
[ObservableProperty] private string _commitType = "chore";
|
||||||
@@ -18,6 +23,7 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string? _systemPromptOverride;
|
[ObservableProperty] private string? _systemPromptOverride;
|
||||||
[ObservableProperty] private AgentInfo? _selectedAgent;
|
[ObservableProperty] private AgentInfo? _selectedAgent;
|
||||||
public List<AgentInfo> AvailableAgents { get; set; } = [];
|
public List<AgentInfo> AvailableAgents { get; set; } = [];
|
||||||
|
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
|
||||||
|
|
||||||
private string? _editId;
|
private string? _editId;
|
||||||
private string _listId = "";
|
private string _listId = "";
|
||||||
@@ -34,11 +40,28 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
public static string[] StatusChoices { get; } =
|
public static string[] StatusChoices { get; } =
|
||||||
["manual", "queued"];
|
["manual", "queued"];
|
||||||
|
|
||||||
|
public TaskEditorViewModel(SubtaskRepository subtaskRepo)
|
||||||
|
{
|
||||||
|
_subtaskRepo = subtaskRepo;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task LoadAgentsAsync(WorkerClient worker)
|
public async Task LoadAgentsAsync(WorkerClient worker)
|
||||||
{
|
{
|
||||||
AvailableAgents = await worker.GetAgentsAsync();
|
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 =>
|
public IReadOnlyList<string> SelectedTagNames =>
|
||||||
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
@@ -51,8 +74,54 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
_createdAt = DateTime.UtcNow;
|
_createdAt = DateTime.UtcNow;
|
||||||
CommitType = defaultCommitType;
|
CommitType = defaultCommitType;
|
||||||
WindowTitle = "New Task";
|
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)
|
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
|
||||||
{
|
{
|
||||||
_editId = entity.Id;
|
_editId = entity.Id;
|
||||||
@@ -72,14 +141,34 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
|
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
|
||||||
: "(list default)";
|
: "(list default)";
|
||||||
SystemPromptOverride = entity.SystemPrompt;
|
SystemPromptOverride = entity.SystemPrompt;
|
||||||
SelectedAgent = entity.AgentPath is not null
|
|
||||||
? AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath)
|
if (entity.AgentPath is not null)
|
||||||
: 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}";
|
WindowTitle = $"Edit Task: {entity.Title}";
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[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;
|
if (string.IsNullOrWhiteSpace(Title)) return;
|
||||||
var status = StatusChoice switch
|
var status = StatusChoice switch
|
||||||
@@ -87,9 +176,10 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
"queued" => TaskStatus.Queued,
|
"queued" => TaskStatus.Queued,
|
||||||
_ => TaskStatus.Manual,
|
_ => TaskStatus.Manual,
|
||||||
};
|
};
|
||||||
|
var taskId = _editId ?? Guid.NewGuid().ToString();
|
||||||
var entity = new TaskEntity
|
var entity = new TaskEntity
|
||||||
{
|
{
|
||||||
Id = _editId ?? Guid.NewGuid().ToString(),
|
Id = taskId,
|
||||||
ListId = _listId,
|
ListId = _listId,
|
||||||
Title = Title.Trim(),
|
Title = Title.Trim(),
|
||||||
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
|
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
|
||||||
@@ -102,6 +192,42 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
: null;
|
: null;
|
||||||
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
|
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
|
||||||
entity.AgentPath = SelectedAgent?.Path;
|
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);
|
_tcs.TrySetResult(entity);
|
||||||
RequestClose?.Invoke();
|
RequestClose?.Invoke();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
|
var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
|
||||||
var editor = _editorFactory();
|
var editor = _editorFactory();
|
||||||
await editor.LoadAgentsAsync(_worker);
|
await editor.LoadAgentsAsync(_worker);
|
||||||
editor.InitForEdit(entity, taskTags);
|
await editor.InitForEditAsync(entity, taskTags);
|
||||||
|
|
||||||
var window = new TaskEditorView { DataContext = editor };
|
var window = new TaskEditorView { DataContext = editor };
|
||||||
editor.RequestClose += () => window.Close();
|
editor.RequestClose += () => window.Close();
|
||||||
|
|||||||
@@ -86,6 +86,71 @@
|
|||||||
PlaceholderText="Add a description..."
|
PlaceholderText="Add a description..."
|
||||||
LostFocus="OnFieldLostFocus"/>
|
LostFocus="OnFieldLostFocus"/>
|
||||||
|
|
||||||
|
<!-- Sub-Tasks -->
|
||||||
|
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,8,0,4"/>
|
||||||
|
<TextBlock Text="Sub-Tasks" FontWeight="Bold" FontSize="13"
|
||||||
|
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Subtasks}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,2,0,2">
|
||||||
|
<CheckBox IsChecked="{Binding Completed}" VerticalAlignment="Center"/>
|
||||||
|
<TextBox Text="{Binding Title}" PlaceholderText="Subtask title..." Width="220"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
LostFocus="OnSubtaskTitleLostFocus"/>
|
||||||
|
<Button Content="✕" Padding="6,2"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Foreground="{StaticResource TextMutedBrush}"
|
||||||
|
Command="{Binding $parent[UserControl].((vm:TaskDetailViewModel)DataContext).RemoveSubtaskCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<Button Content="+ Add Sub-Task" Command="{Binding AddSubtaskCommand}"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Foreground="{StaticResource AccentLightBrush}" HorizontalAlignment="Left"/>
|
||||||
|
|
||||||
|
<!-- Agent Config (overrides) -->
|
||||||
|
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,8,0,4"/>
|
||||||
|
<TextBlock Text="Agent Config (overrides)" FontWeight="Bold" FontSize="13"
|
||||||
|
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,12,*" Margin="0,4,0,0">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Model" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||||
|
<ComboBox ItemsSource="{Binding ModelChoices}"
|
||||||
|
SelectedItem="{Binding ModelChoice}"
|
||||||
|
MinWidth="100"
|
||||||
|
LostFocus="OnFieldLostFocus"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Text="Agent File" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
|
<ComboBox ItemsSource="{Binding AvailableAgents}"
|
||||||
|
SelectedItem="{Binding SelectedAgent}"
|
||||||
|
MinWidth="100"
|
||||||
|
LostFocus="OnFieldLostFocus">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="m:AgentInfo">
|
||||||
|
<TextBlock Text="{Binding Name}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<Button Content="…" Click="OnBrowseAgent" ToolTip.Tip="Browse for agent .md file"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="System Prompt" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,2"/>
|
||||||
|
<TextBox Text="{Binding SystemPromptOverride}"
|
||||||
|
PlaceholderText="(inherits from list)"
|
||||||
|
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="50"
|
||||||
|
LostFocus="OnFieldLostFocus"/>
|
||||||
|
|
||||||
<!-- === READ-ONLY ZONE === -->
|
<!-- === READ-ONLY ZONE === -->
|
||||||
|
|
||||||
<TextBlock Text="Result" FontWeight="SemiBold" FontSize="12"
|
<TextBlock Text="Result" FontWeight="SemiBold" FontSize="12"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.ComponentModel;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views;
|
namespace ClaudeDo.Ui.Views;
|
||||||
@@ -19,6 +20,31 @@ public partial class TaskDetailView : UserControl
|
|||||||
await vm.SaveAsync();
|
await vm.SaveAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnSubtaskTitleLostFocus(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
// Title change is handled by SubtaskItemViewModel.PropertyChanged → OnSubtaskPropertyChanged in the VM
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnBrowseAgent(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
if (topLevel is null) return;
|
||||||
|
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Select Agent File",
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter = new[] { new FilePickerFileType("Agent Files") { Patterns = ["*.md"] } },
|
||||||
|
});
|
||||||
|
if (files.Count == 0) return;
|
||||||
|
var path = files[0].TryGetLocalPath();
|
||||||
|
if (path is null) return;
|
||||||
|
if (DataContext is TaskDetailViewModel vm)
|
||||||
|
{
|
||||||
|
vm.SetAgentFromPath(path);
|
||||||
|
await vm.SaveAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnTagInputKeyDown(object? sender, KeyEventArgs e)
|
private void OnTagInputKeyDown(object? sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Key == Key.Enter && DataContext is TaskDetailViewModel vm)
|
if (e.Key == Key.Enter && DataContext is TaskDetailViewModel vm)
|
||||||
|
|||||||
@@ -35,6 +35,30 @@
|
|||||||
<TextBlock Text="Tags (comma-separated)" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
|
<TextBlock Text="Tags (comma-separated)" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||||
<TextBox Text="{Binding TagsInput}" PlaceholderText="agent, manual, code, ..."/>
|
<TextBox Text="{Binding TagsInput}" PlaceholderText="agent, manual, code, ..."/>
|
||||||
|
|
||||||
|
<!-- Sub-Tasks -->
|
||||||
|
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
|
||||||
|
<TextBlock Text="Sub-Tasks" FontWeight="Bold" FontSize="13"
|
||||||
|
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Subtasks}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,2,0,2">
|
||||||
|
<CheckBox IsChecked="{Binding Completed}" VerticalAlignment="Center"/>
|
||||||
|
<TextBox Text="{Binding Title}" PlaceholderText="Subtask title..." Width="320"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Content="✕" Padding="6,2"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Foreground="{StaticResource TextMutedBrush}"
|
||||||
|
Command="{Binding $parent[Window].((vm:TaskEditorViewModel)DataContext).RemoveSubtaskCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<Button Content="+ Add Sub-Task" Command="{Binding AddSubtaskCommand}"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Foreground="{StaticResource AccentLightBrush}" HorizontalAlignment="Left"/>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
|
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
|
||||||
|
|
||||||
@@ -55,6 +79,7 @@
|
|||||||
|
|
||||||
<TextBlock Text="Agent File" FontWeight="SemiBold"
|
<TextBlock Text="Agent File" FontWeight="SemiBold"
|
||||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<ComboBox ItemsSource="{Binding AvailableAgents}"
|
<ComboBox ItemsSource="{Binding AvailableAgents}"
|
||||||
SelectedItem="{Binding SelectedAgent}"
|
SelectedItem="{Binding SelectedAgent}"
|
||||||
MinWidth="150">
|
MinWidth="150">
|
||||||
@@ -64,6 +89,8 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
<Button Content="…" Click="OnBrowseAgent" ToolTip.Tip="Browse for agent .md file"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0">
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||||
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"
|
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views;
|
namespace ClaudeDo.Ui.Views;
|
||||||
|
|
||||||
@@ -8,4 +11,19 @@ public partial class TaskEditorView : Window
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnBrowseAgent(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Select Agent File",
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter = new[] { new FilePickerFileType("Agent Files") { Patterns = ["*.md"] } },
|
||||||
|
});
|
||||||
|
if (files.Count == 0) return;
|
||||||
|
var path = files[0].TryGetLocalPath();
|
||||||
|
if (path is null) return;
|
||||||
|
if (DataContext is TaskEditorViewModel vm)
|
||||||
|
vm.SetAgentFromPath(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user