From 8577c55685c14361f681b7c8c70606123252b9cb Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 15 Apr 2026 11:19:41 +0200 Subject: [PATCH 1/3] feat(ui): remove MaxWidth on main columns to use full window width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lists (320px) and Detail (500px) borders no longer cap the 3-column grid — star-sizing (1*:2*:1.5*) now fills the window, reducing the dead whitespace between columns. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ClaudeDo.Ui/Views/MainWindow.axaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml b/src/ClaudeDo.Ui/Views/MainWindow.axaml index fef54ce..2d80721 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml @@ -18,7 +18,7 @@ + MinWidth="180" Margin="0,0,4,8" ClipToBounds="True"> + MinWidth="280" Margin="4,0,0,8" ClipToBounds="True"> -- 2.49.1 From 8c051d8f620bacff9820ce9a50c7f7aa024a240c Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 15 Apr 2026 11:19:54 +0200 Subject: [PATCH 2/3] feat(data): add subtasks table, repository and prompt integration Per-task checklist backend: subtasks table with CASCADE delete, SubtaskEntity + SubtaskRepository (connection-per-op, async), DI registration in App and Worker, TaskRunner composes a '## Sub-Tasks' markdown block into the Claude prompt when subtasks exist. Co-Authored-By: Claude Opus 4.6 (1M context) --- schema/schema.sql | 10 +++ src/ClaudeDo.App/Program.cs | 4 +- src/ClaudeDo.Data/Models/SubtaskEntity.cs | 11 +++ .../Repositories/SubtaskRepository.cs | 81 +++++++++++++++++++ src/ClaudeDo.Worker/Program.cs | 1 + src/ClaudeDo.Worker/Runner/TaskRunner.cs | 16 +++- .../Services/QueueServiceTests.cs | 3 +- 7 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 src/ClaudeDo.Data/Models/SubtaskEntity.cs create mode 100644 src/ClaudeDo.Data/Repositories/SubtaskRepository.cs diff --git a/schema/schema.sql b/schema/schema.sql index 5f65955..1e75c85 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -85,6 +85,16 @@ CREATE TABLE IF NOT EXISTS task_runs ( CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id); +CREATE TABLE IF NOT EXISTS subtasks ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + title TEXT NOT NULL, + completed INTEGER NOT NULL DEFAULT 0, + order_num INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_subtasks_task_id ON subtasks(task_id); + -- Seed: minimal tag set (ignored if already present) INSERT OR IGNORE INTO tags (name) VALUES ('agent'); INSERT OR IGNORE INTO tags (name) VALUES ('manual'); diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 7a5ce37..b5442c2 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -49,6 +49,7 @@ sealed class Program // Repositories sc.AddSingleton(); sc.AddSingleton(); + sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); @@ -66,7 +67,8 @@ sealed class Program sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); sc.AddSingleton(sp => { var taskRepo = sp.GetRequiredService(); diff --git a/src/ClaudeDo.Data/Models/SubtaskEntity.cs b/src/ClaudeDo.Data/Models/SubtaskEntity.cs new file mode 100644 index 0000000..dbe0c4c --- /dev/null +++ b/src/ClaudeDo.Data/Models/SubtaskEntity.cs @@ -0,0 +1,11 @@ +namespace ClaudeDo.Data.Models; + +public sealed class SubtaskEntity +{ + public required string Id { get; init; } + public required string TaskId { get; init; } + public required string Title { get; set; } + public bool Completed { get; set; } + public int OrderNum { get; set; } + public required DateTime CreatedAt { get; init; } +} diff --git a/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs b/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs new file mode 100644 index 0000000..68a77b9 --- /dev/null +++ b/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs @@ -0,0 +1,81 @@ +using ClaudeDo.Data.Models; +using Microsoft.Data.Sqlite; + +namespace ClaudeDo.Data.Repositories; + +public sealed class SubtaskRepository +{ + private readonly SqliteConnectionFactory _factory; + + public SubtaskRepository(SqliteConnectionFactory factory) => _factory = factory; + + public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default) + { + await using var conn = _factory.Open(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT id, task_id, title, completed, order_num, created_at FROM subtasks WHERE task_id = @task_id ORDER BY order_num"; + cmd.Parameters.AddWithValue("@task_id", taskId); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + var result = new List(); + while (await reader.ReadAsync(ct)) + result.Add(ReadSubtask(reader)); + return result; + } + + public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default) + { + await using var conn = _factory.Open(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO subtasks (id, task_id, title, completed, order_num, created_at) + VALUES (@id, @task_id, @title, @completed, @order_num, @created_at) + """; + BindSubtask(cmd, entity); + await cmd.ExecuteNonQueryAsync(ct); + } + + public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default) + { + await using var conn = _factory.Open(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE subtasks SET title = @title, completed = @completed, order_num = @order_num + WHERE id = @id + """; + cmd.Parameters.AddWithValue("@id", entity.Id); + cmd.Parameters.AddWithValue("@title", entity.Title); + cmd.Parameters.AddWithValue("@completed", entity.Completed ? 1 : 0); + cmd.Parameters.AddWithValue("@order_num", entity.OrderNum); + await cmd.ExecuteNonQueryAsync(ct); + } + + public async Task DeleteAsync(string id, CancellationToken ct = default) + { + await using var conn = _factory.Open(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM subtasks WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", id); + await cmd.ExecuteNonQueryAsync(ct); + } + + private static void BindSubtask(SqliteCommand cmd, SubtaskEntity e) + { + cmd.Parameters.AddWithValue("@id", e.Id); + cmd.Parameters.AddWithValue("@task_id", e.TaskId); + cmd.Parameters.AddWithValue("@title", e.Title); + cmd.Parameters.AddWithValue("@completed", e.Completed ? 1 : 0); + cmd.Parameters.AddWithValue("@order_num", e.OrderNum); + cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o")); + } + + private static SubtaskEntity ReadSubtask(SqliteDataReader r) => new() + { + Id = r.GetString(0), + TaskId = r.GetString(1), + Title = r.GetString(2), + Completed = r.GetInt64(3) != 0, + OrderNum = r.GetInt32(4), + CreatedAt = DateTime.Parse(r.GetString(5)), + }; +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 84af2ec..15220a5 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -19,6 +19,7 @@ builder.Services.AddSingleton(dbFactory); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index d40cad1..63cfcb2 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -12,6 +12,7 @@ public sealed class TaskRunner private readonly TaskRunRepository _runRepo; private readonly ListRepository _listRepo; private readonly WorktreeRepository _wtRepo; + private readonly SubtaskRepository _subtaskRepo; private readonly HubBroadcaster _broadcaster; private readonly WorktreeManager _wtManager; private readonly ClaudeArgsBuilder _argsBuilder; @@ -24,6 +25,7 @@ public sealed class TaskRunner TaskRunRepository runRepo, ListRepository listRepo, WorktreeRepository wtRepo, + SubtaskRepository subtaskRepo, HubBroadcaster broadcaster, WorktreeManager wtManager, ClaudeArgsBuilder argsBuilder, @@ -35,6 +37,7 @@ public sealed class TaskRunner _runRepo = runRepo; _listRepo = listRepo; _wtRepo = wtRepo; + _subtaskRepo = subtaskRepo; _broadcaster = broadcaster; _wtManager = wtManager; _argsBuilder = argsBuilder; @@ -91,9 +94,16 @@ public sealed class TaskRunner await _broadcaster.TaskStarted(slot, task.Id, now); // Build prompt. - var prompt = string.IsNullOrWhiteSpace(task.Description) - ? task.Title - : $"{task.Title}\n\n{task.Description.Trim()}"; + var subtasks = await _subtaskRepo.GetByTaskIdAsync(task.Id, ct); + var sb = new System.Text.StringBuilder(task.Title); + if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim()); + if (subtasks.Count > 0) + { + sb.Append("\n\n## Sub-Tasks\n"); + foreach (var s in subtasks) + sb.Append(s.Completed ? "- [x] " : "- [ ] ").Append(s.Title).Append('\n'); + } + var prompt = sb.ToString(); // Run 1. var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct); diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index 3d3d5c5..fc2fe1d 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -51,7 +51,8 @@ public sealed class QueueServiceTests : IDisposable var runRepo = new TaskRunRepository(_db.Factory); var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger.Instance); var argsBuilder = new ClaudeArgsBuilder(); - var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, broadcaster, wtManager, argsBuilder, _cfg, + var subtaskRepo = new SubtaskRepository(_db.Factory); + var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, subtaskRepo, broadcaster, wtManager, argsBuilder, _cfg, NullLogger.Instance); var service = new QueueService(_taskRepo, runner, _cfg, NullLogger.Instance); return (service, fake); -- 2.49.1 From 9a407bde83fa9a20b77f6472591d9c7143e22943 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 15 Apr 2026 11:20:17 +0200 Subject: [PATCH 3/3] feat(ui): agent config inline in detail panel, file picker, subtask UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ViewModels/SubtaskItemViewModel.cs | 23 +++ .../ViewModels/TaskDetailViewModel.cs | 111 +++++++++++++- .../ViewModels/TaskEditorViewModel.cs | 136 +++++++++++++++++- .../ViewModels/TaskListViewModel.cs | 2 +- src/ClaudeDo.Ui/Views/TaskDetailView.axaml | 65 +++++++++ src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs | 26 ++++ src/ClaudeDo.Ui/Views/TaskEditorView.axaml | 45 ++++-- src/ClaudeDo.Ui/Views/TaskEditorView.axaml.cs | 18 +++ 8 files changed, 410 insertions(+), 16 deletions(-) create mode 100644 src/ClaudeDo.Ui/ViewModels/SubtaskItemViewModel.cs diff --git a/src/ClaudeDo.Ui/ViewModels/SubtaskItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/SubtaskItemViewModel.cs new file mode 100644 index 0000000..7eea857 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/SubtaskItemViewModel.cs @@ -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, + }; +} diff --git a/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs index 63b30c9..aa5cb90 100644 --- a/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs @@ -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 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 Tags { get; } = new(); [ObservableProperty] private string _newTagInput = ""; + public ObservableCollection Subtasks { get; } = new(); private string? _taskId; private string? _listId; @@ -52,7 +59,8 @@ public partial class TaskDetailViewModel : ViewModelBase public event Action? 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(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) diff --git a/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs index b388e0b..9a172b6 100644 --- a/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs @@ -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 AvailableAgents { get; set; } = []; + public ObservableCollection 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 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 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 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(); } diff --git a/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs index 62129db..16afd51 100644 --- a/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs @@ -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(); diff --git a/src/ClaudeDo.Ui/Views/TaskDetailView.axaml b/src/ClaudeDo.Ui/Views/TaskDetailView.axaml index 1fb121a..69e97c3 100644 --- a/src/ClaudeDo.Ui/Views/TaskDetailView.axaml +++ b/src/ClaudeDo.Ui/Views/TaskDetailView.axaml @@ -86,6 +86,71 @@ PlaceholderText="Add a description..." LostFocus="OnFieldLostFocus"/> + + + + + + + + + +