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);
+ }
+}