10 Commits

Author SHA1 Message Date
mika kuns
450e685580 docs(open): add planning-session manual verification checklist 2026-04-23 19:32:34 +02:00
mika kuns
0e116bec7b feat(ui): friendly error when deleting task with children 2026-04-23 19:22:28 +02:00
mika kuns
47b49743c0 feat(ui): unfinished planning session dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:19:16 +02:00
mika kuns
506caa2c53 feat(ui): draft and planning badge styles 2026-04-23 19:04:26 +02:00
mika kuns
388a8c1fae feat(ui): planning entries in task context menu 2026-04-23 19:02:06 +02:00
mika kuns
42b208ff28 feat(ui): TaskRowView hierarchy indentation, chevron, badges, draft italic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:58:08 +02:00
mika kuns
309f84b388 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 <noreply@anthropic.com>
2026-04-23 18:51:22 +02:00
mika kuns
00608401aa feat(ui): WorkerClient planning-session methods 2026-04-23 18:41:04 +02:00
mika kuns
229d4bbb2b feat(ui): TaskRowViewModel gains planning hierarchy flags
Adds ParentTaskId, IsExpanded, IsChild, IsPlanningParent, IsDraft, and
PlanningBadge to TaskRowViewModel with property-changed notifications.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:39:44 +02:00
845359b885 feat: planning sessions foundation (Plan A) (#4)
Merges Plan A: schema + repos + auto-parent-completion hook.
2026-04-23 16:31:37 +00:00
20 changed files with 823 additions and 126 deletions

View File

@@ -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. 7. Repeat step 6 with **Continue anyway** → wizard opens without self-update.
8. Repeat step 6 with **Cancel** → installer exits without any action. 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). 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.

View File

@@ -17,7 +17,9 @@
<converters:EqStatusConverter x:Key="EqStatus"/> <converters:EqStatusConverter x:Key="EqStatus"/>
<converters:UpperCaseConverter x:Key="UpperCase"/> <converters:UpperCaseConverter x:Key="UpperCase"/>
<converters:IconKeyConverter x:Key="IconKey"/> <converters:IconKeyConverter x:Key="IconKey"/>
<converters:DotBrushConverter x:Key="DotBrush"/> <converters:DotBrushConverter x:Key="DotBrush"/>
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
</ResourceDictionary> </ResourceDictionary>
</Application.Resources> </Application.Resources>

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -84,6 +84,11 @@
<!-- Icon.Settings (gear) --> <!-- Icon.Settings (gear) -->
<StreamGeometry x:Key="Icon.Settings">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</StreamGeometry> <StreamGeometry x:Key="Icon.Settings">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</StreamGeometry>
<!-- Badge brushes -->
<SolidColorBrush x:Key="DraftBadgeBrush" Color="#4A5568"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="#D69E2E"/>
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="#3182CE"/>
</Styles.Resources> </Styles.Resources>
<!-- ============================================================ --> <!-- ============================================================ -->
@@ -866,4 +871,31 @@
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" /> <Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
</Style> </Style>
<!-- ============================================================ -->
<!-- PLANNING / DRAFT BADGES -->
<!-- ============================================================ -->
<Style Selector="Border.badge">
<Setter Property="CornerRadius" Value="3"/>
<Setter Property="Padding" Value="4,1"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style Selector="Border.badge > TextBlock">
<Setter Property="FontSize" Value="9"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Border.badge.draft">
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
</Style>
<Style Selector="Border.badge.planning">
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
</Style>
<Style Selector="Border.badge.planned">
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
</Style>
</Styles> </Styles>

View File

@@ -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<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
}

View File

@@ -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);

View File

@@ -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;
@@ -347,6 +347,33 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
} }
} }
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
=> 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);
async Task<int> IWorkerClient.GetPendingDraftCountAsync(string taskId, CancellationToken ct)
=> await GetPendingDraftCountAsync(taskId, ct);
// DTOs for deserializing hub responses // DTOs for deserializing hub responses
private sealed class ActiveTaskDto private sealed class ActiveTaskDto
{ {

View File

@@ -139,6 +139,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting // Set by the view so DeleteTaskCommand can prompt yes/no before deleting
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; } public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
// Set by the view so DeleteTaskCommand can show an error message
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker, IServiceProvider services) public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker, IServiceProvider services)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
@@ -537,9 +540,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone."); var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
if (!ok) return; if (!ok) return;
} }
await using var ctx = _dbFactory.CreateDbContext(); try
var repo = new TaskRepository(ctx); {
await repo.DeleteAsync(row.Id); 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) if (DeleteFromList != null)
await DeleteFromList(row); await DeleteFromList(row);
CloseDetail?.Invoke(); CloseDetail?.Invoke();

View File

@@ -23,6 +23,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private int _diffDeletions; [ObservableProperty] private int _diffDeletions;
[ObservableProperty] private bool _dropHintAbove; [ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow; [ObservableProperty] private bool _dropHintBelow;
[ObservableProperty] private string? _parentTaskId;
[ObservableProperty] private bool _isExpanded = true;
public DateTime CreatedAt { get; init; } public DateTime CreatedAt { get; init; }
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}"; 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 StepsCount { get; init; }
public int StepsCompleted { 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 HasBranch => !string.IsNullOrWhiteSpace(Branch);
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0; public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
public bool HasTags => Tags.Count > 0; public bool HasTags => Tags.Count > 0;
@@ -60,6 +76,17 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsQueued)); OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(HasLiveTail)); 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)); partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
@@ -91,6 +118,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
DiffAdditions = add, DiffAdditions = add,
DiffDeletions = del, DiffDeletions = del,
CreatedAt = t.CreatedAt, CreatedAt = t.CreatedAt,
ParentTaskId = t.ParentTaskId,
}; };
} }

View File

@@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -13,7 +14,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 +43,9 @@ 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 Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker; _worker = worker;
@@ -105,14 +109,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 +381,79 @@ 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;
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) 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);

View File

@@ -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<UnfinishedPlanningModalResult> 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(); }
}

View File

@@ -45,9 +45,49 @@ public partial class DetailsIslandView : UserControl
}; };
vm.ConfirmAsync = ShowConfirmAsync; 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<bool> ShowConfirmAsync(string message) private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;

View File

@@ -15,134 +15,189 @@
Background="{DynamicResource MossBrush}" CornerRadius="1" Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/> IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="task-row" <!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
Margin="0" <Grid Grid.Row="1" ColumnDefinitions="Auto,*">
Classes.selected="{Binding IsSelected}"
Classes.done="{Binding Done}"> <!-- Indent track (only visible for child tasks) -->
<Border.ContextMenu> <Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
<ContextMenu> <Rectangle Width="1" Fill="{DynamicResource LineBrush}"
<MenuItem Header="Send to queue" HorizontalAlignment="Right" Margin="0,4"/>
IsVisible="{Binding !IsQueued}" </Border>
Click="OnSendToQueueClick"/>
<MenuItem Header="Remove from queue" <!-- Main task card -->
<Border Grid.Column="1" Classes="task-row"
Margin="0"
Classes.selected="{Binding IsSelected}"
Classes.done="{Binding Done}">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Send to queue"
IsVisible="{Binding !IsQueued}"
Click="OnSendToQueueClick"/>
<MenuItem Header="Remove from queue"
IsVisible="{Binding IsQueued}"
Click="OnRemoveFromQueueClick"/>
<Separator/>
<MenuItem Header="Open planning Session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).OpenPlanningSessionCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding CanOpenPlanningSession}"/>
<MenuItem Header="Resume planning Session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ResumePlanningSessionCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="Discard planning session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).DiscardPlanningSessionCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<Separator/>
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
<MenuItem Header="Clear schedule"
IsVisible="{Binding HasSchedule}"
Click="OnClearScheduleClick"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="0,18,32,*,Auto,32" Margin="6,8,10,8">
<!-- Chevron toggle (only for planning parent tasks) -->
<Button Grid.Column="1"
IsVisible="{Binding IsPlanningParent}"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleExpandCommand}"
CommandParameter="{Binding}"
Classes="icon-btn"
Width="18" Height="18"
VerticalAlignment="Center">
<Panel>
<TextBlock Text="▾" FontSize="10" IsVisible="{Binding IsExpanded}"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
<TextBlock Text="▸" FontSize="10" IsVisible="{Binding !IsExpanded}"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Panel>
</Button>
<!-- Done toggle -->
<Button Grid.Column="2" Classes="flat" VerticalAlignment="Top"
Margin="0,2,0,0"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleDoneCommand}"
CommandParameter="{Binding}">
<Ellipse Width="18" Height="18" Classes="task-check"
Classes.done="{Binding Done}"/>
</Button>
<!-- Title + chip row + live tail -->
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<TextBlock Classes="task-title"
Text="{Binding Title}" FontSize="14"
Foreground="{DynamicResource TextBrush}"
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
<!-- Badges: DRAFT and planning session -->
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
<TextBlock Text="DRAFT"/>
</Border>
<Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
<TextBlock Text="{Binding PlanningBadge}"/>
</Border>
</StackPanel>
</StackPanel>
<!-- Chip row -->
<StackPanel Orientation="Horizontal" Spacing="6">
<!-- Status chip -->
<Border Classes="chip"
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
<TextBlock Text="{Binding Status}"/>
</Border>
<!-- Dequeue button (only when Queued) -->
<Button Classes="icon-btn dequeue-btn"
IsVisible="{Binding IsQueued}" IsVisible="{Binding IsQueued}"
Click="OnRemoveFromQueueClick"/> ToolTip.Tip="Remove from queue"
<Separator/> Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/> CommandParameter="{Binding}">
<MenuItem Header="Clear schedule" <PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
IsVisible="{Binding HasSchedule}" </Button>
Click="OnClearScheduleClick"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="0,32,*,32" Margin="6,8,10,8">
<!-- Done toggle --> <!-- List chip with dot -->
<Button Grid.Column="1" Classes="flat" VerticalAlignment="Top" <Border Classes="chip chip-list">
Margin="0,2,0,0" <StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleDoneCommand}" <Ellipse Width="6" Height="6"
CommandParameter="{Binding}"> Fill="{DynamicResource MossBrush}"
<Ellipse Width="18" Height="18" Classes="task-check" VerticalAlignment="Center"/>
Classes.done="{Binding Done}"/> <TextBlock Text="{Binding ListName}"/>
</Button> </StackPanel>
</Border>
<!-- Title + chip row + live tail --> <!-- Branch chip -->
<StackPanel Grid.Column="2" Spacing="6" VerticalAlignment="Center"> <Border Classes="chip chip-branch" IsVisible="{Binding HasBranch}">
<TextBlock Classes="task-title" <StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
Text="{Binding Title}" FontSize="14" <PathIcon Width="10" Height="10"
Foreground="{DynamicResource TextBrush}" Data="{StaticResource Icon.GitBranch}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/> Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Text="{Binding Branch}"/>
</StackPanel>
</Border>
<!-- Chip row --> <!-- Diff chip -->
<StackPanel Orientation="Horizontal" Spacing="6"> <Border Classes="chip chip-diff" IsVisible="{Binding HasDiff}">
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<TextBlock Classes="diff-add" Text="{Binding DiffAdditionsText}"/>
<TextBlock Classes="diff-del" Text="{Binding DiffDeletionsText}"/>
</StackPanel>
</Border>
<!-- Status chip --> <!-- Tag chips -->
<Border Classes="chip" <ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}" <ItemsControl.ItemsPanel>
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}" <ItemsPanelTemplate>
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}" <StackPanel Orientation="Horizontal" Spacing="6"/>
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}"> </ItemsPanelTemplate>
<TextBlock Text="{Binding Status}"/> </ItemsControl.ItemsPanel>
</Border> <ItemsControl.ItemTemplate>
<DataTemplate>
<Border Classes="chip chip-tag">
<TextBlock Text="{Binding}"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Dequeue button (only when Queued) --> <!-- Live-tail row (visible when running + has tail) -->
<Button Classes="icon-btn dequeue-btn" <Border Classes="task-live-tail" IsVisible="{Binding HasLiveTail}">
IsVisible="{Binding IsQueued}" <StackPanel Spacing="3">
ToolTip.Tip="Remove from queue" <TextBlock Text="{Binding LiveTail}"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}" TextTrimming="CharacterEllipsis" MaxLines="1"/>
CommandParameter="{Binding}"> <Grid Height="3" HorizontalAlignment="Stretch">
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/> <Rectangle Fill="{DynamicResource Surface3Brush}"
</Button> HorizontalAlignment="Stretch" RadiusX="1.5" RadiusY="1.5"/>
<Rectangle Fill="{DynamicResource MossBrush}"
<!-- List chip with dot --> HorizontalAlignment="Left" Width="60" RadiusX="1.5" RadiusY="1.5"/>
<Border Classes="chip chip-list"> </Grid>
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse Width="6" Height="6"
Fill="{DynamicResource MossBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding ListName}"/>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Branch chip -->
<Border Classes="chip chip-branch" IsVisible="{Binding HasBranch}">
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<PathIcon Width="10" Height="10"
Data="{StaticResource Icon.GitBranch}"
Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Text="{Binding Branch}"/>
</StackPanel>
</Border>
<!-- Diff chip -->
<Border Classes="chip chip-diff" IsVisible="{Binding HasDiff}">
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<TextBlock Classes="diff-add" Text="{Binding DiffAdditionsText}"/>
<TextBlock Classes="diff-del" Text="{Binding DiffDeletionsText}"/>
</StackPanel>
</Border>
<!-- Tag chips -->
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="6"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Classes="chip chip-tag">
<TextBlock Text="{Binding}"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel> </StackPanel>
<!-- Live-tail row (visible when running + has tail) --> <!-- Star toggle -->
<Border Classes="task-live-tail" IsVisible="{Binding HasLiveTail}"> <Button Grid.Column="5" Classes="icon-btn star-btn"
<StackPanel Spacing="3"> Classes.on="{Binding IsStarred}"
<TextBlock Text="{Binding LiveTail}" VerticalAlignment="Top" Margin="0,2,0,0"
TextTrimming="CharacterEllipsis" MaxLines="1"/> Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
<Grid Height="3" HorizontalAlignment="Stretch"> CommandParameter="{Binding}">
<Rectangle Fill="{DynamicResource Surface3Brush}" <PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
HorizontalAlignment="Stretch" RadiusX="1.5" RadiusY="1.5"/> </Button>
<Rectangle Fill="{DynamicResource MossBrush}" </Grid>
HorizontalAlignment="Left" Width="60" RadiusX="1.5" RadiusY="1.5"/> </Border>
</Grid>
</StackPanel>
</Border>
</StackPanel>
<!-- Star toggle --> </Grid>
<Button Grid.Column="3" Classes="icon-btn star-btn"
Classes.on="{Binding IsStarred}"
VerticalAlignment="Top" Margin="0,2,0,0"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
CommandParameter="{Binding}">
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
</Button>
</Grid>
</Border>
<!-- Below-row indicator: only expands when visible (used for the last row of a section) --> <!-- Below-row indicator: only expands when visible (used for the last row of a section) -->
<Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}"> <Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}">

View File

@@ -4,6 +4,8 @@ using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.Views.Modals;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
@@ -19,7 +21,19 @@ public partial class TasksIslandView : UserControl
DataContextChanged += (_, _) => DataContextChanged += (_, _) =>
{ {
if (DataContext is TasksIslandViewModel vm) if (DataContext is TasksIslandViewModel vm)
{
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus(); 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).
};
}
}; };
} }

View File

@@ -0,0 +1,82 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.UnfinishedPlanningModalView"
x:DataType="vm:UnfinishedPlanningModalViewModel"
Title="Unfinished planning session"
Width="440" Height="200"
CanResize="False"
SystemDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings>
<Window.Styles>
<Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource DeepBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</Window.Styles>
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1">
<Grid RowDefinitions="36,*,52">
<!-- Title bar -->
<Border Grid.Row="0"
x:Name="TitleBar"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
PointerPressed="TitleBar_PointerPressed">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="UNFINISHED PLANNING SESSION"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
LetterSpacing="1.4"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Classes="icon-btn"
Content="✕"
FontSize="12"
Command="{Binding CancelCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Body -->
<StackPanel Grid.Row="1" Margin="20,16" Spacing="8">
<TextBlock Text="{Binding TaskTitle}"
FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}"
TextTrimming="CharacterEllipsis"/>
<TextBlock Foreground="{DynamicResource TextDimBrush}">
<Run Text="{Binding DraftCount}"/>
<Run Text=" draft task(s) waiting to be finalized."/>
</TextBlock>
</StackPanel>
<!-- Footer -->
<Border Grid.Row="2"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="16,0">
<Button Content="Discard" Command="{Binding DiscardCommand}" MinWidth="80"/>
<Button Content="Finalize" Command="{Binding FinalizeNowCommand}" MinWidth="80"/>
<Button Content="Resume" Command="{Binding ResumeCommand}" Classes="primary" MinWidth="80"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>

View File

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

View File

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

View File

@@ -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<int> 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<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);
}
}