diff --git a/docs/open.md b/docs/open.md
index a12020d..15baac9 100644
--- a/docs/open.md
+++ b/docs/open.md
@@ -207,3 +207,22 @@ Preconditions: a working Gitea release at `git.kuns.dev/releases/ClaudeDo` with
7. Repeat step 6 with **Continue anyway** → wizard opens without self-update.
8. Repeat step 6 with **Cancel** → installer exits without any action.
9. Kill network during startup in both app and installer → confirm silent fallback (no errors, no banner, wizard opens normally).
+
+---
+
+## Planning Sessions — Manual Verification (Plan C UI)
+
+Requires Plan B (worker hub endpoints) merged. Until then, only the UI structure/styling checks are meaningful.
+
+1. Create a Manual task with a title and a TODO-ish description.
+2. Right-click the task → **Open planning Session** — Windows Terminal opens with Claude CLI running (Plan B).
+3. Ask Claude to create two child tasks via `mcp__claudedo__create_child_task`.
+4. Watch the UI: drafts appear indented under the parent, italic, reduced opacity, with a `DRAFT` badge.
+5. The parent shows a `PLANNING` badge. Click the chevron → children collapse; click again → children expand.
+6. Ask Claude to `finalize` — drafts flip to Manual/Queued children; parent flips to `PLANNED` badge.
+7. In a new planning task, close the terminal without finalize. Right-click the Planning task → the unfinished-session modal opens with Resume / Finalize now / Discard.
+8. Attempt to delete a parent with children via the details panel — confirm the friendly error dialog appears and the task is NOT deleted.
+
+**Known followups (non-blocking):**
+- `Border.badge.planned` style (blue) is defined in `IslandStyles.axaml` but never applied — `TaskRowView` keeps the `planning` class for both Planning and Planned, so Planned gets the amber badge. Either make the view swap `Classes.planned` when status is Planned, or remove the unused style + brush.
+- Dead `Instance` statics on `BoolToItalicConverter` and `BoolToDraftOpacityConverter` — App.axaml registers instances via the resource dictionary; the static members can be removed.
diff --git a/src/ClaudeDo.App/App.axaml b/src/ClaudeDo.App/App.axaml
index 0c7445f..e7e3f98 100644
--- a/src/ClaudeDo.App/App.axaml
+++ b/src/ClaudeDo.App/App.axaml
@@ -17,7 +17,9 @@
-
+
+
+
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/Converters/BoolToDraftOpacityConverter.cs b/src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs
new file mode 100644
index 0000000..e2d8d04
--- /dev/null
+++ b/src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+
+namespace ClaudeDo.Ui.Converters;
+
+public sealed class BoolToDraftOpacityConverter : IValueConverter
+{
+ public static BoolToDraftOpacityConverter Instance { get; } = new();
+
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is true ? 0.7 : 1.0;
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs b/src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs
new file mode 100644
index 0000000..0017101
--- /dev/null
+++ b/src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+
+namespace ClaudeDo.Ui.Converters;
+
+public sealed class BoolToItalicConverter : IValueConverter
+{
+ public static BoolToItalicConverter Instance { get; } = new();
+
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is true ? FontStyle.Italic : FontStyle.Normal;
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml
index 0cd8837..5cbc092 100644
--- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml
+++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml
@@ -84,6 +84,11 @@
M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z
+
+
+
+
+
@@ -866,4 +871,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Ui/Services/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/IWorkerClient.cs
new file mode 100644
index 0000000..08cf2fc
--- /dev/null
+++ b/src/ClaudeDo.Ui/Services/IWorkerClient.cs
@@ -0,0 +1,11 @@
+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);
+ Task GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
+}
diff --git a/src/ClaudeDo.Ui/Services/PlanningDtos.cs b/src/ClaudeDo.Ui/Services/PlanningDtos.cs
new file mode 100644
index 0000000..71a553e
--- /dev/null
+++ b/src/ClaudeDo.Ui/Services/PlanningDtos.cs
@@ -0,0 +1,18 @@
+namespace ClaudeDo.Ui.Services;
+
+public sealed record PlanningSessionFilesDto(
+ string SessionDirectory,
+ string McpConfigPath,
+ string SystemPromptPath,
+ string InitialPromptPath);
+
+public sealed record PlanningSessionStartInfo(
+ string ParentTaskId,
+ string WorkingDir,
+ PlanningSessionFilesDto Files);
+
+public sealed record PlanningSessionResumeInfo(
+ string ParentTaskId,
+ string WorkingDir,
+ string ClaudeSessionId,
+ string McpConfigPath);
diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs
index 0612c37..a330714 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;
@@ -347,6 +347,33 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
}
}
+ public async Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
+ => await _hub.InvokeAsync("StartPlanningSessionAsync", taskId, ct);
+
+ public async Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
+ => await _hub.InvokeAsync("ResumePlanningSessionAsync", taskId, ct);
+
+ public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
+ => await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
+
+ public async Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
+ => await _hub.InvokeAsync("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
+
+ 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);
+ async Task IWorkerClient.GetPendingDraftCountAsync(string taskId, CancellationToken ct)
+ => await GetPendingDraftCountAsync(taskId, ct);
+
// DTOs for deserializing hub responses
private sealed class ActiveTaskDto
{
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
index 461710b..29cc32f 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
@@ -139,6 +139,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
public Func>? ConfirmAsync { get; set; }
+ // Set by the view so DeleteTaskCommand can show an error message
+ public Func? ShowErrorAsync { get; set; }
+
public DetailsIslandViewModel(IDbContextFactory dbFactory, WorkerClient worker, IServiceProvider services)
{
_dbFactory = dbFactory;
@@ -537,9 +540,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
if (!ok) return;
}
- await using var ctx = _dbFactory.CreateDbContext();
- var repo = new TaskRepository(ctx);
- await repo.DeleteAsync(row.Id);
+ try
+ {
+ await using var ctx = _dbFactory.CreateDbContext();
+ var repo = new TaskRepository(ctx);
+ await repo.DeleteAsync(row.Id);
+ }
+ catch (DbUpdateException ex) when (
+ ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
+ || ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)
+ {
+ if (ShowErrorAsync != null)
+ await ShowErrorAsync("This task has child tasks. Discard the planning session or delete child tasks first.");
+ return;
+ }
if (DeleteFromList != null)
await DeleteFromList(row);
CloseDetail?.Invoke();
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
index 2fe8ddc..3d4a5bc 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
@@ -23,6 +23,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private int _diffDeletions;
[ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow;
+ [ObservableProperty] private string? _parentTaskId;
+ [ObservableProperty] private bool _isExpanded = true;
public DateTime CreatedAt { get; init; }
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
@@ -31,6 +33,20 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public int StepsCount { get; init; }
public int StepsCompleted { get; init; }
+ public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
+ public bool IsPlanningParent => Status == TaskStatus.Planning || Status == TaskStatus.Planned;
+ public bool IsDraft => Status == TaskStatus.Draft;
+
+ public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
+ public bool CanResumeOrDiscardPlanning => Status == TaskStatus.Planning;
+
+ public string? PlanningBadge => Status switch
+ {
+ TaskStatus.Planning => "PLANNING",
+ TaskStatus.Planned => "PLANNED",
+ _ => null,
+ };
+
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
public bool HasTags => Tags.Count > 0;
@@ -60,6 +76,17 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(HasLiveTail));
+ OnPropertyChanged(nameof(IsPlanningParent));
+ OnPropertyChanged(nameof(PlanningBadge));
+ OnPropertyChanged(nameof(IsDraft));
+ OnPropertyChanged(nameof(CanOpenPlanningSession));
+ OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
+ }
+
+ partial void OnParentTaskIdChanged(string? value)
+ {
+ OnPropertyChanged(nameof(IsChild));
+ OnPropertyChanged(nameof(CanOpenPlanningSession));
}
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
@@ -91,6 +118,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
DiffAdditions = add,
DiffDeletions = del,
CreatedAt = t.CreatedAt,
+ ParentTaskId = t.ParentTaskId,
};
}
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
index 904a654..baa02c9 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
@@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
+using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -13,7 +14,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 +43,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
[ObservableProperty] private bool _showOpenLabel;
[ObservableProperty] private string _completedHeader = "COMPLETED";
- public TasksIslandViewModel(IDbContextFactory dbFactory, WorkerClient? worker = null)
+ public Func? ShowUnfinishedPlanningModal { get; set; }
+
+ public TasksIslandViewModel(IDbContextFactory dbFactory, IWorkerClient? worker = null)
{
_dbFactory = dbFactory;
_worker = worker;
@@ -105,14 +109,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 +381,79 @@ 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;
+ if (_worker is null) return;
+ try
+ {
+ var draftCount = await _worker.GetPendingDraftCountAsync(row.Id);
+ var modalVm = new UnfinishedPlanningModalViewModel
+ {
+ TaskTitle = row.Title,
+ DraftCount = draftCount,
+ };
+
+ if (ShowUnfinishedPlanningModal is null)
+ return;
+ await ShowUnfinishedPlanningModal(modalVm);
+
+ var choice = await modalVm.Result.Task;
+
+ switch (choice)
+ {
+ case UnfinishedPlanningModalResult.Resume:
+ await _worker.ResumePlanningSessionAsync(row.Id);
+ break;
+ case UnfinishedPlanningModalResult.FinalizeNow:
+ await _worker.FinalizePlanningSessionAsync(row.Id);
+ break;
+ case UnfinishedPlanningModalResult.Discard:
+ await _worker.DiscardPlanningSessionAsync(row.Id);
+ break;
+ case UnfinishedPlanningModalResult.Cancel:
+ default:
+ break;
+ }
+ }
+ 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/src/ClaudeDo.Ui/ViewModels/Modals/UnfinishedPlanningModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/UnfinishedPlanningModalViewModel.cs
new file mode 100644
index 0000000..d0f23a4
--- /dev/null
+++ b/src/ClaudeDo.Ui/ViewModels/Modals/UnfinishedPlanningModalViewModel.cs
@@ -0,0 +1,27 @@
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClaudeDo.Ui.ViewModels.Modals;
+
+public enum UnfinishedPlanningModalResult
+{
+ Cancel,
+ Resume,
+ FinalizeNow,
+ Discard,
+}
+
+public sealed partial class UnfinishedPlanningModalViewModel : ViewModelBase
+{
+ [ObservableProperty] private string _taskTitle = "";
+ [ObservableProperty] private int _draftCount;
+
+ public TaskCompletionSource Result { get; } = new();
+ public Action? CloseAction { get; set; }
+
+ [RelayCommand] private void Resume() { Result.TrySetResult(UnfinishedPlanningModalResult.Resume); CloseAction?.Invoke(); }
+ [RelayCommand] private void FinalizeNow() { Result.TrySetResult(UnfinishedPlanningModalResult.FinalizeNow); CloseAction?.Invoke(); }
+ [RelayCommand] private void Discard() { Result.TrySetResult(UnfinishedPlanningModalResult.Discard); CloseAction?.Invoke(); }
+ [RelayCommand] private void Cancel() { Result.TrySetResult(UnfinishedPlanningModalResult.Cancel); CloseAction?.Invoke(); }
+}
diff --git a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs
index 9d345a8..62016b7 100644
--- a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs
@@ -45,9 +45,49 @@ public partial class DetailsIslandView : UserControl
};
vm.ConfirmAsync = ShowConfirmAsync;
+ vm.ShowErrorAsync = ShowErrorDialogAsync;
}
}
+ private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
+ {
+ var owner = TopLevel.GetTopLevel(this) as Window;
+ if (owner == null) return;
+
+ var ok = new Button { Content = "OK", MinWidth = 90 };
+
+ var dialog = new Window
+ {
+ Title = "Error",
+ Width = 360,
+ SizeToContent = SizeToContent.Height,
+ CanResize = false,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ ShowInTaskbar = false,
+ Background = this.FindResource("SurfaceBrush") as IBrush,
+ Content = new StackPanel
+ {
+ Spacing = 16,
+ Margin = new Thickness(20),
+ Children =
+ {
+ new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
+ new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Children = { ok }
+ }
+ }
+ }
+ };
+
+ ok.Click += (_, _) => dialog.Close();
+
+ await dialog.ShowDialog(owner);
+ }
+
private async System.Threading.Tasks.Task ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
index f7a7ed1..1e4bdb1 100644
--- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
+++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
@@ -15,134 +15,189 @@
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
-
-
-
-
-
-
-
+ ToolTip.Tip="Remove from queue"
+ Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
+ CommandParameter="{Binding}">
+
+
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
+
diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs
index 3137855..882efa1 100644
--- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs
@@ -4,6 +4,8 @@ using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands;
+using ClaudeDo.Ui.ViewModels.Modals;
+using ClaudeDo.Ui.Views.Modals;
namespace ClaudeDo.Ui.Views.Islands;
@@ -19,7 +21,19 @@ public partial class TasksIslandView : UserControl
DataContextChanged += (_, _) =>
{
if (DataContext is TasksIslandViewModel vm)
+ {
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
+ vm.ShowUnfinishedPlanningModal = async (modalVm) =>
+ {
+ var owner = TopLevel.GetTopLevel(this) as Window;
+ if (owner is null) { modalVm.CancelCommand.Execute(null); return; }
+ var modal = new UnfinishedPlanningModalView { DataContext = modalVm };
+ // Closing via the OS title-bar (if ever enabled) also resolves the TCS.
+ modal.Closed += (_, _) => modalVm.CancelCommand.Execute(null);
+ await modal.ShowDialog(owner);
+ // ShowDialog completes once the window is closed (CloseAction or OS close).
+ };
+ }
};
}
diff --git a/src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModalView.axaml
new file mode 100644
index 0000000..7e02abd
--- /dev/null
+++ b/src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModalView.axaml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModalView.axaml.cs b/src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModalView.axaml.cs
new file mode 100644
index 0000000..fb88972
--- /dev/null
+++ b/src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModalView.axaml.cs
@@ -0,0 +1,23 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+
+namespace ClaudeDo.Ui.Views.Modals;
+
+public partial class UnfinishedPlanningModalView : Window
+{
+ public UnfinishedPlanningModalView()
+ {
+ InitializeComponent();
+ DataContextChanged += (_, _) =>
+ {
+ if (DataContext is ViewModels.Modals.UnfinishedPlanningModalViewModel vm)
+ vm.CloseAction = () => Close();
+ };
+ }
+
+ private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ BeginMoveDrag(e);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs
new file mode 100644
index 0000000..8ac6d38
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs
@@ -0,0 +1,45 @@
+using ClaudeDo.Data.Models;
+using ClaudeDo.Ui.ViewModels.Islands;
+using Xunit;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Worker.Tests.UiVm;
+
+public class TaskRowViewModelPlanningTests
+{
+ private static TaskRowViewModel MakeRow(TaskStatus status, string? parentTaskId = null)
+ => new TaskRowViewModel { Id = "t", Status = status, ParentTaskId = parentTaskId };
+
+ [Fact]
+ public void Draft_Status_SetsIsChildFlag_WhenParentIdIsNotNull()
+ {
+ var vm = MakeRow(TaskStatus.Draft, "parent-id");
+ Assert.True(vm.IsChild);
+ Assert.False(vm.IsPlanningParent);
+ }
+
+ [Fact]
+ public void Planning_Status_SetsIsPlanningParent()
+ {
+ var vm = MakeRow(TaskStatus.Planning);
+ Assert.True(vm.IsPlanningParent);
+ Assert.False(vm.IsChild);
+ Assert.Equal("PLANNING", vm.PlanningBadge);
+ }
+
+ [Fact]
+ public void Planned_Status_ShowsPlannedBadge()
+ {
+ var vm = MakeRow(TaskStatus.Planned);
+ Assert.True(vm.IsPlanningParent);
+ Assert.Equal("PLANNED", vm.PlanningBadge);
+ }
+
+ [Fact]
+ public void NonPlanningStatus_NoBadge()
+ {
+ var vm = MakeRow(TaskStatus.Manual);
+ Assert.False(vm.IsPlanningParent);
+ Assert.Null(vm.PlanningBadge);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
new file mode 100644
index 0000000..04a2b7c
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
@@ -0,0 +1,130 @@
+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; }
+ public Task GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
+}
+
+// ── 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);
+ }
+}