From 309f84b388e4bbdd2b7f9a8b0cac5b891c5b0601 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:51:22 +0200 Subject: [PATCH] feat(ui): planning commands and expand/collapse in TasksIslandViewModel - Add IWorkerClient interface; WorkerClient implements it - TasksIslandViewModel accepts IWorkerClient? and gains OpenPlanningSession, ResumePlanningSession, DiscardPlanningSession, FinalizePlanningSession, and ToggleExpand commands - Regroup() is hierarchy-aware: children of collapsed planning parents are hidden - InternalsVisibleTo ClaudeDo.Worker.Tests for Regroup() - 4 new unit tests covering collapse/expand and guard logic Co-Authored-By: Claude Sonnet 4.6 --- src/ClaudeDo.Ui/ClaudeDo.Ui.csproj | 1 + src/ClaudeDo.Ui/Services/IWorkerClient.cs | 10 ++ src/ClaudeDo.Ui/Services/WorkerClient.cs | 12 +- .../Islands/TasksIslandViewModel.cs | 72 +++++++++- .../UiVm/TasksIslandViewModelPlanningTests.cs | 129 ++++++++++++++++++ 5 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 src/ClaudeDo.Ui/Services/IWorkerClient.cs create mode 100644 tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj index 2ea6f07..7a19886 100644 --- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj @@ -15,6 +15,7 @@ + diff --git a/src/ClaudeDo.Ui/Services/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/IWorkerClient.cs new file mode 100644 index 0000000..a904c50 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/IWorkerClient.cs @@ -0,0 +1,10 @@ +namespace ClaudeDo.Ui.Services; + +public interface IWorkerClient +{ + Task WakeQueueAsync(); + Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default); + Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default); + Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default); + Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default); +} diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 56775c3..57566e6 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -25,7 +25,7 @@ sealed class IndefiniteRetryPolicy : IRetryPolicy _delays[Math.Min(retryContext.PreviousRetryCount, _delays.Length - 1)]; } -public partial class WorkerClient : ObservableObject, IAsyncDisposable +public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerClient { private readonly HubConnection _hub; private CancellationTokenSource? _startCts; @@ -362,6 +362,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable public async Task GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => await _hub.InvokeAsync("GetPendingDraftCountAsync", taskId, ct); + // IWorkerClient explicit implementations (drop typed return values) + async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct) + => await StartPlanningSessionAsync(taskId, ct); + async Task IWorkerClient.ResumePlanningSessionAsync(string taskId, CancellationToken ct) + => await ResumePlanningSessionAsync(taskId, ct); + async Task IWorkerClient.DiscardPlanningSessionAsync(string taskId, CancellationToken ct) + => await DiscardPlanningSessionAsync(taskId, ct); + async Task IWorkerClient.FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks, CancellationToken ct) + => await FinalizePlanningSessionAsync(taskId, queueAgentTasks, ct); + // DTOs for deserializing hub responses private sealed class ActiveTaskDto { diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 904a654..4111a86 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -13,7 +13,8 @@ namespace ClaudeDo.Ui.ViewModels.Islands; public sealed partial class TasksIslandViewModel : ViewModelBase { private readonly IDbContextFactory _dbFactory; - private readonly WorkerClient? _worker; + private readonly IWorkerClient? _worker; + private readonly Dictionary _expandedState = new(); private ListNavItemViewModel? _currentList; private CancellationTokenSource? _loadCts; @@ -41,7 +42,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase [ObservableProperty] private bool _showOpenLabel; [ObservableProperty] private string _completedHeader = "COMPLETED"; - public TasksIslandViewModel(IDbContextFactory dbFactory, WorkerClient? worker = null) + public TasksIslandViewModel(IDbContextFactory dbFactory, IWorkerClient? worker = null) { _dbFactory = dbFactory; _worker = worker; @@ -105,14 +106,35 @@ public sealed partial class TasksIslandViewModel : ViewModelBase catch (OperationCanceledException) { } } - private void Regroup() + internal void Regroup() { OverdueItems.Clear(); OpenItems.Clear(); CompletedItems.Clear(); - var today = DateTime.Today; + // Restore IsExpanded from saved state foreach (var r in Items) + { + if (_expandedState.TryGetValue(r.Id, out var saved)) + r.IsExpanded = saved; + } + + // Build hierarchy-aware flat list: top-level rows interleaved with visible children. + // Items is already ordered by SortOrder from the DB query. + var topLevel = Items.Where(r => !r.IsChild); + var flat = new List(); + foreach (var parent in topLevel) + { + flat.Add(parent); + if (parent.IsPlanningParent && parent.IsExpanded) + { + var children = Items.Where(r => r.ParentTaskId == parent.Id); + flat.AddRange(children); + } + } + + var today = DateTime.Today; + foreach (var r in flat) { if (r.Done) CompletedItems.Add(r); @@ -356,6 +378,48 @@ public sealed partial class TasksIslandViewModel : ViewModelBase [RelayCommand] private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty); + [RelayCommand] + private async Task OpenPlanningSessionAsync(TaskRowViewModel? row) + { + if (row is null || row.Status != TaskStatus.Manual) return; + try { await _worker!.StartPlanningSessionAsync(row.Id); } + catch { } + } + + [RelayCommand] + private async Task ResumePlanningSessionAsync(TaskRowViewModel? row) + { + if (row is null || !row.IsPlanningParent) return; + try { await _worker!.ResumePlanningSessionAsync(row.Id); } + catch { } + } + + [RelayCommand] + private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row) + { + if (row is null) return; + try { await _worker!.DiscardPlanningSessionAsync(row.Id); } + catch { } + } + + [RelayCommand] + private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row) + { + if (row is null) return; + try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); } + catch { } + } + + [RelayCommand] + private void ToggleExpand(TaskRowViewModel? row) + { + if (row is null) return; + var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : row.IsExpanded); + _expandedState[row.Id] = next; + row.IsExpanded = next; + Regroup(); + } + partial void OnSelectedTaskChanged(TaskRowViewModel? value) { foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value); diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs new file mode 100644 index 0000000..ea9b67f --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -0,0 +1,129 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Islands; +using CommunityToolkit.Mvvm.Input; +using Microsoft.EntityFrameworkCore; +using Xunit; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.UiVm; + +// ── Fake worker client ──────────────────────────────────────────────────────── + +sealed class FakeWorkerClient : IWorkerClient +{ + public int StartPlanningCalls { get; private set; } + public int ResumePlanningCalls { get; private set; } + public int DiscardPlanningCalls { get; private set; } + public int FinalizePlanningCalls { get; private set; } + public int WakeQueueCalls { get; private set; } + + public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; } + public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; } + public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; } + public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) { DiscardPlanningCalls++; return Task.CompletedTask; } + public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { FinalizePlanningCalls++; return Task.CompletedTask; } +} + +// ── Helper to build VM with pre-seeded Items ────────────────────────────────── + +file static class VmFactory +{ + // Minimal SQLite :memory: factory — never actually called in these tests + // (we seed Items directly), but required by the VM constructor. + private static IDbContextFactory NullDbFactory() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + return new NullDbContextFactory(opts); + } + + private sealed class NullDbContextFactory(DbContextOptions opts) + : IDbContextFactory + { + public ClaudeDoDbContext CreateDbContext() => new(opts); + } + + public static (TasksIslandViewModel vm, FakeWorkerClient worker) Create( + IEnumerable rows) + { + var worker = new FakeWorkerClient(); + var vm = new TasksIslandViewModel(NullDbFactory(), worker); + foreach (var r in rows) + vm.Items.Add(r); + vm.Regroup(); + return (vm, worker); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +public class TasksIslandViewModelPlanningTests +{ + private static TaskRowViewModel MakeRow(string id, TaskStatus status, string? parentId = null, int sortOrder = 0) + => new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId }; + + [Fact] + public void ToggleExpand_CollapsesChildrenOfPlanningParent() + { + var parent = MakeRow("p1", TaskStatus.Planning); + var child1 = MakeRow("c1", TaskStatus.Draft, "p1"); + var child2 = MakeRow("c2", TaskStatus.Draft, "p1"); + + var (vm, _) = VmFactory.Create([parent, child1, child2]); + + // Initially expanded — children visible in OpenItems + Assert.Contains(child1, vm.OpenItems); + Assert.Contains(child2, vm.OpenItems); + + // Collapse the parent + vm.ToggleExpandCommand.Execute(parent); + + // Children should no longer appear + Assert.DoesNotContain(child1, vm.OpenItems); + Assert.DoesNotContain(child2, vm.OpenItems); + // Parent still present + Assert.Contains(parent, vm.OpenItems); + } + + [Fact] + public async Task OpenPlanningSession_IgnoresNonManualRow() + { + var row = MakeRow("t1", TaskStatus.Queued); + var (vm, worker) = VmFactory.Create([row]); + + await ((IAsyncRelayCommand)vm.OpenPlanningSessionCommand).ExecuteAsync(row); + + Assert.Equal(0, worker.StartPlanningCalls); + } + + [Fact] + public async Task OpenPlanningSession_CallsWorkerForManualRow() + { + var row = MakeRow("t1", TaskStatus.Manual); + var (vm, worker) = VmFactory.Create([row]); + + await ((IAsyncRelayCommand)vm.OpenPlanningSessionCommand).ExecuteAsync(row); + + Assert.Equal(1, worker.StartPlanningCalls); + } + + [Fact] + public void ToggleExpand_ExpandsCollapsedParentAgain() + { + var parent = MakeRow("p1", TaskStatus.Planned); + var child = MakeRow("c1", TaskStatus.Draft, "p1"); + + var (vm, _) = VmFactory.Create([parent, child]); + + // Collapse + vm.ToggleExpandCommand.Execute(parent); + Assert.DoesNotContain(child, vm.OpenItems); + + // Re-expand + vm.ToggleExpandCommand.Execute(parent); + Assert.Contains(child, vm.OpenItems); + } +}