feat(ui): planning sessions UI (Plan C) #5
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="ClaudeDo.Ui.Tests" />
|
<InternalsVisibleTo Include="ClaudeDo.Ui.Tests" />
|
||||||
|
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
10
src/ClaudeDo.Ui/Services/IWorkerClient.cs
Normal file
10
src/ClaudeDo.Ui/Services/IWorkerClient.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ sealed class IndefiniteRetryPolicy : IRetryPolicy
|
|||||||
_delays[Math.Min(retryContext.PreviousRetryCount, _delays.Length - 1)];
|
_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 readonly HubConnection _hub;
|
||||||
private CancellationTokenSource? _startCts;
|
private CancellationTokenSource? _startCts;
|
||||||
@@ -362,6 +362,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
|
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
|
=> await _hub.InvokeAsync<int>("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
|
// DTOs for deserializing hub responses
|
||||||
private sealed class ActiveTaskDto
|
private sealed class ActiveTaskDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
|||||||
public sealed partial class TasksIslandViewModel : ViewModelBase
|
public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly WorkerClient? _worker;
|
private readonly IWorkerClient? _worker;
|
||||||
|
private readonly Dictionary<string, bool> _expandedState = new();
|
||||||
private ListNavItemViewModel? _currentList;
|
private ListNavItemViewModel? _currentList;
|
||||||
private CancellationTokenSource? _loadCts;
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _showOpenLabel;
|
[ObservableProperty] private bool _showOpenLabel;
|
||||||
[ObservableProperty] private string _completedHeader = "COMPLETED";
|
[ObservableProperty] private string _completedHeader = "COMPLETED";
|
||||||
|
|
||||||
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient? worker = null)
|
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
@@ -105,14 +106,35 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Regroup()
|
internal void Regroup()
|
||||||
{
|
{
|
||||||
OverdueItems.Clear();
|
OverdueItems.Clear();
|
||||||
OpenItems.Clear();
|
OpenItems.Clear();
|
||||||
CompletedItems.Clear();
|
CompletedItems.Clear();
|
||||||
|
|
||||||
var today = DateTime.Today;
|
// Restore IsExpanded from saved state
|
||||||
foreach (var r in Items)
|
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<TaskRowViewModel>();
|
||||||
|
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)
|
if (r.Done)
|
||||||
CompletedItems.Add(r);
|
CompletedItems.Add(r);
|
||||||
@@ -356,6 +378,48 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty);
|
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)
|
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
|
||||||
{
|
{
|
||||||
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
|
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
|
||||||
|
|||||||
@@ -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<ClaudeDoDbContext> NullDbFactory()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite("DataSource=:memory:")
|
||||||
|
.Options;
|
||||||
|
return new NullDbContextFactory(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullDbContextFactory(DbContextOptions<ClaudeDoDbContext> opts)
|
||||||
|
: IDbContextFactory<ClaudeDoDbContext>
|
||||||
|
{
|
||||||
|
public ClaudeDoDbContext CreateDbContext() => new(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (TasksIslandViewModel vm, FakeWorkerClient worker) Create(
|
||||||
|
IEnumerable<TaskRowViewModel> 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<TaskRowViewModel?>)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<TaskRowViewModel?>)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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user