Compare commits
27 Commits
feat/plann
...
e455d85578
| Author | SHA1 | Date | |
|---|---|---|---|
| e455d85578 | |||
|
|
0782ba574b | ||
|
|
7b67e35720 | ||
|
|
c048264b95 | ||
|
|
6cb20a9213 | ||
|
|
99c6a71e4c | ||
|
|
0088d6e0e0 | ||
|
|
b115a4c512 | ||
|
|
9e09ae6b4e | ||
|
|
43a3740980 | ||
|
|
d28164caf4 | ||
|
|
77f7cf1423 | ||
|
|
84e6c2d5fc | ||
|
|
84b0ba8670 | ||
|
|
b6bec1e63c | ||
|
|
b32621a4e5 | ||
| 993851009b | |||
|
|
450e685580 | ||
|
|
0e116bec7b | ||
|
|
47b49743c0 | ||
|
|
506caa2c53 | ||
|
|
388a8c1fae | ||
|
|
42b208ff28 | ||
|
|
309f84b388 | ||
|
|
00608401aa | ||
|
|
229d4bbb2b | ||
| 845359b885 |
19
docs/open.md
19
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.
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
<converters:EqStatusConverter x:Key="EqStatus"/>
|
||||
<converters:UpperCaseConverter x:Key="UpperCase"/>
|
||||
<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>
|
||||
</Application.Resources>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
@@ -8,6 +10,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260416064948_InitialCreate")]
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260420075929_AddTaskFlagsAndNotes")]
|
||||
public partial class AddTaskFlagsAndNotes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260421113614_AddAppSettings")]
|
||||
public partial class AddAppSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
@@ -5,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260422120000_AddTaskSortOrder")]
|
||||
public partial class AddTaskSortOrder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
@@ -6,6 +8,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260423154708_AddPlanningSupport")]
|
||||
public partial class AddPlanningSupport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -267,6 +267,23 @@ public sealed class TaskRepository
|
||||
return child;
|
||||
}
|
||||
|
||||
public async Task UpdatePlanningTaskAsync(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct)
|
||||
?? throw new InvalidOperationException("Planning task not found.");
|
||||
if (title is not null) entity.Title = title;
|
||||
if (description is not null) entity.Description = description;
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Title, entity.Title)
|
||||
.SetProperty(t => t.Description, entity.Description), ct);
|
||||
}
|
||||
|
||||
public async Task<TaskEntity?> SetPlanningStartedAsync(
|
||||
string taskId,
|
||||
string sessionToken,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClaudeDo.Ui.Tests" />
|
||||
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
15
src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs
Normal file
15
src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs
Normal 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();
|
||||
}
|
||||
16
src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs
Normal file
16
src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs
Normal 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();
|
||||
}
|
||||
@@ -84,6 +84,11 @@
|
||||
<!-- 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>
|
||||
|
||||
<!-- Badge brushes -->
|
||||
<SolidColorBrush x:Key="DraftBadgeBrush" Color="#4A5568"/>
|
||||
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="#D69E2E"/>
|
||||
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="#3182CE"/>
|
||||
|
||||
</Styles.Resources>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
@@ -866,4 +871,31 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
||||
</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>
|
||||
|
||||
11
src/ClaudeDo.Ui/Services/IWorkerClient.cs
Normal file
11
src/ClaudeDo.Ui/Services/IWorkerClient.cs
Normal 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);
|
||||
}
|
||||
18
src/ClaudeDo.Ui/Services/PlanningDtos.cs
Normal file
18
src/ClaudeDo.Ui/Services/PlanningDtos.cs
Normal 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);
|
||||
@@ -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<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
|
||||
private sealed class ActiveTaskDto
|
||||
{
|
||||
|
||||
@@ -139,6 +139,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
||||
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)
|
||||
{
|
||||
_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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly WorkerClient? _worker;
|
||||
private readonly IWorkerClient? _worker;
|
||||
private readonly Dictionary<string, bool> _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<ClaudeDoDbContext> dbFactory, WorkerClient? worker = null)
|
||||
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
|
||||
|
||||
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> 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<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)
|
||||
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);
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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<bool> ShowConfirmAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
|
||||
@@ -15,134 +15,189 @@
|
||||
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
||||
IsVisible="{Binding DropHintAbove}"/>
|
||||
|
||||
<Border Grid.Row="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"
|
||||
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||
|
||||
<!-- Indent track (only visible for child tasks) -->
|
||||
<Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
|
||||
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
|
||||
HorizontalAlignment="Right" Margin="0,4"/>
|
||||
</Border>
|
||||
|
||||
<!-- 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}"
|
||||
Click="OnRemoveFromQueueClick"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
|
||||
<MenuItem Header="Clear schedule"
|
||||
IsVisible="{Binding HasSchedule}"
|
||||
Click="OnClearScheduleClick"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="0,32,*,32" Margin="6,8,10,8">
|
||||
ToolTip.Tip="Remove from queue"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
|
||||
</Button>
|
||||
|
||||
<!-- Done toggle -->
|
||||
<Button Grid.Column="1" 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>
|
||||
<!-- List chip with dot -->
|
||||
<Border Classes="chip chip-list">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
||||
<Ellipse Width="6" Height="6"
|
||||
Fill="{DynamicResource MossBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding ListName}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Title + chip row + live tail -->
|
||||
<StackPanel Grid.Column="2" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Classes="task-title"
|
||||
Text="{Binding Title}" FontSize="14"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Chip row -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Dequeue button (only when Queued) -->
|
||||
<Button Classes="icon-btn dequeue-btn"
|
||||
IsVisible="{Binding IsQueued}"
|
||||
ToolTip.Tip="Remove from queue"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
|
||||
</Button>
|
||||
|
||||
<!-- List chip with dot -->
|
||||
<Border Classes="chip chip-list">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
||||
<Ellipse Width="6" Height="6"
|
||||
Fill="{DynamicResource MossBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding ListName}"/>
|
||||
<!-- Live-tail row (visible when running + has tail) -->
|
||||
<Border Classes="task-live-tail" IsVisible="{Binding HasLiveTail}">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="{Binding LiveTail}"
|
||||
TextTrimming="CharacterEllipsis" MaxLines="1"/>
|
||||
<Grid Height="3" HorizontalAlignment="Stretch">
|
||||
<Rectangle Fill="{DynamicResource Surface3Brush}"
|
||||
HorizontalAlignment="Stretch" RadiusX="1.5" RadiusY="1.5"/>
|
||||
<Rectangle Fill="{DynamicResource MossBrush}"
|
||||
HorizontalAlignment="Left" Width="60" RadiusX="1.5" RadiusY="1.5"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</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>
|
||||
|
||||
<!-- Live-tail row (visible when running + has tail) -->
|
||||
<Border Classes="task-live-tail" IsVisible="{Binding HasLiveTail}">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="{Binding LiveTail}"
|
||||
TextTrimming="CharacterEllipsis" MaxLines="1"/>
|
||||
<Grid Height="3" HorizontalAlignment="Stretch">
|
||||
<Rectangle Fill="{DynamicResource Surface3Brush}"
|
||||
HorizontalAlignment="Stretch" RadiusX="1.5" RadiusY="1.5"/>
|
||||
<Rectangle Fill="{DynamicResource MossBrush}"
|
||||
HorizontalAlignment="Left" Width="60" RadiusX="1.5" RadiusY="1.5"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- Star toggle -->
|
||||
<Button Grid.Column="5" 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>
|
||||
|
||||
<!-- Star toggle -->
|
||||
<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>
|
||||
</Grid>
|
||||
|
||||
<!-- Below-row indicator: only expands when visible (used for the last row of a section) -->
|
||||
<Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}">
|
||||
|
||||
@@ -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).
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Reflection;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Services;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -43,6 +44,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly WorktreeMaintenanceService _wtMaintenance;
|
||||
private readonly TaskResetService _resetService;
|
||||
private readonly TaskMergeService _mergeService;
|
||||
private readonly PlanningSessionManager _planning;
|
||||
private readonly IPlanningTerminalLauncher _launcher;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -52,7 +55,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
WorktreeMaintenanceService wtMaintenance,
|
||||
TaskResetService resetService,
|
||||
TaskMergeService mergeService)
|
||||
TaskMergeService mergeService,
|
||||
PlanningSessionManager planning,
|
||||
IPlanningTerminalLauncher launcher)
|
||||
{
|
||||
_queue = queue;
|
||||
_agentService = agentService;
|
||||
@@ -62,6 +67,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_wtMaintenance = wtMaintenance;
|
||||
_resetService = resetService;
|
||||
_mergeService = mergeService;
|
||||
_planning = planning;
|
||||
_launcher = launcher;
|
||||
}
|
||||
|
||||
public string Ping() => $"pong v{Version}";
|
||||
@@ -284,5 +291,44 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
await _broadcaster.TaskUpdated(dto.TaskId);
|
||||
}
|
||||
|
||||
public async Task<PlanningSessionStartContext> StartPlanningSessionAsync(string taskId)
|
||||
{
|
||||
var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
|
||||
try
|
||||
{
|
||||
await _launcher.LaunchStartAsync(ctx, Context.ConnectionAborted);
|
||||
}
|
||||
catch (PlanningLaunchException)
|
||||
{
|
||||
await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
|
||||
throw;
|
||||
}
|
||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
public async Task<PlanningSessionResumeContext> ResumePlanningSessionAsync(string taskId)
|
||||
{
|
||||
var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
|
||||
await _launcher.LaunchResumeAsync(ctx, Context.ConnectionAborted);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
public async Task DiscardPlanningSessionAsync(string taskId)
|
||||
{
|
||||
await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
|
||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||
}
|
||||
|
||||
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true)
|
||||
{
|
||||
var count = await _planning.FinalizeAsync(taskId, queueAgentTasks, Context.ConnectionAborted);
|
||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||
return count;
|
||||
}
|
||||
|
||||
public Task<int> GetPendingDraftCountAsync(string taskId)
|
||||
=> _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted);
|
||||
|
||||
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
}
|
||||
|
||||
12
src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs
Normal file
12
src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public interface IPlanningTerminalLauncher
|
||||
{
|
||||
Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
|
||||
Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class PlanningLaunchException : Exception
|
||||
{
|
||||
public PlanningLaunchException(string message) : base(message) { }
|
||||
}
|
||||
6
src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs
Normal file
6
src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed class PlanningMcpContext
|
||||
{
|
||||
public required string ParentTaskId { get; init; }
|
||||
}
|
||||
14
src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs
Normal file
14
src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed class PlanningMcpContextAccessor
|
||||
{
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public PlanningMcpContextAccessor(IHttpContextAccessor http) => _http = http;
|
||||
|
||||
public PlanningMcpContext Current =>
|
||||
(_http.HttpContext?.Items["PlanningContext"] as PlanningMcpContext)
|
||||
?? throw new InvalidOperationException("No planning context on request.");
|
||||
}
|
||||
128
src/ClaudeDo.Worker/Planning/PlanningMcpService.cs
Normal file
128
src/ClaudeDo.Worker/Planning/PlanningMcpService.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ModelContextProtocol.Server;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList<string> Tags);
|
||||
public sealed record CreatedChildDto(string TaskId, string Status);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class PlanningMcpService
|
||||
{
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly PlanningMcpContextAccessor _contextAccessor;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
|
||||
public PlanningMcpService(
|
||||
TaskRepository tasks,
|
||||
PlanningMcpContextAccessor contextAccessor,
|
||||
HubBroadcaster broadcaster)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_contextAccessor = contextAccessor;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct)
|
||||
=> _broadcaster.TaskUpdated(taskId);
|
||||
|
||||
[McpServerTool, Description("Create a new draft child task under the current planning session's parent task.")]
|
||||
public async Task<CreatedChildDto> CreateChildTask(
|
||||
string title,
|
||||
string? description,
|
||||
IReadOnlyList<string>? tags,
|
||||
string? commitType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
return new CreatedChildDto(child.Id, "Draft");
|
||||
}
|
||||
|
||||
[McpServerTool, Description("List all child tasks under the current planning session's parent task.")]
|
||||
public async Task<IReadOnlyList<ChildTaskDto>> ListChildTasks(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
|
||||
var list = new List<ChildTaskDto>(children.Count);
|
||||
foreach (var c in children)
|
||||
{
|
||||
var tagList = await _tasks.GetTagsAsync(c.Id, cancellationToken);
|
||||
list.Add(new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString(), tagList.Select(t => t.Name).ToList()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Update an existing draft child task. Only Draft tasks may be modified.")]
|
||||
public async Task<ChildTaskDto> UpdateChildTask(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
IReadOnlyList<string>? tags,
|
||||
string? commitType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (child.ParentTaskId != ctx.ParentTaskId)
|
||||
throw new InvalidOperationException("Task is not a child of this planning session.");
|
||||
if (child.Status != TaskStatus.Draft)
|
||||
throw new InvalidOperationException("Cannot modify a finalized task.");
|
||||
|
||||
if (title is not null) child.Title = title;
|
||||
if (description is not null) child.Description = description;
|
||||
if (commitType is not null) child.CommitType = commitType;
|
||||
await _tasks.UpdateAsync(child, cancellationToken);
|
||||
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList());
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Delete a draft child task. Only Draft tasks may be deleted.")]
|
||||
public async Task DeleteChildTask(
|
||||
string taskId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (child.ParentTaskId != ctx.ParentTaskId)
|
||||
throw new InvalidOperationException("Task is not a child of this planning session.");
|
||||
if (child.Status != TaskStatus.Draft)
|
||||
throw new InvalidOperationException("Cannot delete a finalized task.");
|
||||
|
||||
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Update the title and/or description of the parent planning task itself.")]
|
||||
public async Task UpdatePlanningTask(
|
||||
string? title,
|
||||
string? description,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
await _tasks.UpdatePlanningTaskAsync(ctx.ParentTaskId, title, description, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Finalize the planning session, promoting all draft child tasks to queued or manual status.")]
|
||||
public async Task<int> Finalize(
|
||||
bool queueAgentTasks,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var count = await _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
return count;
|
||||
}
|
||||
}
|
||||
12
src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
Normal file
12
src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed record PlanningSessionStartContext(
|
||||
string ParentTaskId,
|
||||
string WorkingDir,
|
||||
PlanningSessionFiles Files);
|
||||
|
||||
public sealed record PlanningSessionResumeContext(
|
||||
string ParentTaskId,
|
||||
string WorkingDir,
|
||||
string ClaudeSessionId,
|
||||
string McpConfigPath);
|
||||
7
src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
Normal file
7
src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed record PlanningSessionFiles(
|
||||
string SessionDirectory,
|
||||
string McpConfigPath,
|
||||
string SystemPromptPath,
|
||||
string InitialPromptPath);
|
||||
186
src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
Normal file
186
src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed class PlanningSessionManager
|
||||
{
|
||||
private const string McpServerUrl = "http://127.0.0.1:47821/mcp";
|
||||
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext>? _factory;
|
||||
private readonly TaskRepository? _tasksOverride;
|
||||
private readonly ListRepository? _listsOverride;
|
||||
private readonly string _rootDirectory;
|
||||
|
||||
// DI constructor — uses factory so this singleton can create scoped repos per call.
|
||||
public PlanningSessionManager(IDbContextFactory<ClaudeDoDbContext> factory, string rootDirectory)
|
||||
{
|
||||
_factory = factory;
|
||||
_rootDirectory = rootDirectory;
|
||||
}
|
||||
|
||||
// Test constructor — accepts repos directly (single shared context, test-scoped).
|
||||
public PlanningSessionManager(TaskRepository tasks, ListRepository lists, string rootDirectory)
|
||||
{
|
||||
_tasksOverride = tasks;
|
||||
_listsOverride = lists;
|
||||
_rootDirectory = rootDirectory;
|
||||
}
|
||||
|
||||
private (TaskRepository tasks, ListRepository lists, ClaudeDoDbContext? ctx) CreateRepos()
|
||||
{
|
||||
if (_tasksOverride is not null)
|
||||
return (_tasksOverride, _listsOverride!, null);
|
||||
var ctx = _factory!.CreateDbContext();
|
||||
return (new TaskRepository(ctx), new ListRepository(ctx), ctx);
|
||||
}
|
||||
|
||||
public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
var (tasks, lists, ctx) = CreateRepos();
|
||||
await using var _ = ctx;
|
||||
|
||||
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.ParentTaskId is not null)
|
||||
throw new InvalidOperationException("Cannot start a planning session on a child task.");
|
||||
if (task.Status != TaskStatus.Manual)
|
||||
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
|
||||
|
||||
var token = GenerateToken();
|
||||
var started = await tasks.SetPlanningStartedAsync(taskId, token, ct)
|
||||
?? throw new InvalidOperationException("Failed to transition task to Planning.");
|
||||
|
||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||
Directory.CreateDirectory(sessionDir);
|
||||
|
||||
var files = new PlanningSessionFiles(
|
||||
sessionDir,
|
||||
Path.Combine(sessionDir, "mcp.json"),
|
||||
Path.Combine(sessionDir, "system-prompt.md"),
|
||||
Path.Combine(sessionDir, "initial-prompt.txt"));
|
||||
|
||||
await File.WriteAllTextAsync(files.McpConfigPath, BuildMcpConfigJson(token), ct);
|
||||
await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
|
||||
await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);
|
||||
|
||||
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||
return new PlanningSessionStartContext(taskId, list.WorkingDir ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."), files);
|
||||
}
|
||||
|
||||
public async Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
||||
{
|
||||
var (tasks, _, ctx) = CreateRepos();
|
||||
await using var __ = ctx;
|
||||
return await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
|
||||
}
|
||||
|
||||
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
var (tasks, _, ctx) = CreateRepos();
|
||||
await using var __ = ctx;
|
||||
var children = await tasks.GetChildrenAsync(taskId, ct);
|
||||
return children.Count(c => c.Status == TaskStatus.Draft);
|
||||
}
|
||||
|
||||
public async Task DiscardAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
var (tasks, _, ctx) = CreateRepos();
|
||||
await using var __ = ctx;
|
||||
|
||||
var ok = await tasks.DiscardPlanningAsync(taskId, ct);
|
||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||
if (Directory.Exists(sessionDir))
|
||||
{
|
||||
try { Directory.Delete(sessionDir, recursive: true); }
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
if (!ok)
|
||||
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
|
||||
}
|
||||
|
||||
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
var (tasks, lists, ctx) = CreateRepos();
|
||||
await using var _ = ctx;
|
||||
|
||||
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status != TaskStatus.Planning)
|
||||
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
|
||||
if (string.IsNullOrEmpty(task.PlanningSessionId))
|
||||
throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
|
||||
|
||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||
var mcpConfigPath = Path.Combine(sessionDir, "mcp.json");
|
||||
if (!File.Exists(mcpConfigPath))
|
||||
throw new InvalidOperationException($"Session directory missing: {sessionDir}");
|
||||
|
||||
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||
return new PlanningSessionResumeContext(taskId, list.WorkingDir ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."), task.PlanningSessionId, mcpConfigPath);
|
||||
}
|
||||
|
||||
private static string GenerateToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
}
|
||||
|
||||
private static string BuildMcpConfigJson(string token)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
mcpServers = new
|
||||
{
|
||||
claudedo = new
|
||||
{
|
||||
type = "http",
|
||||
url = McpServerUrl,
|
||||
headers = new Dictionary<string, string>
|
||||
{
|
||||
["Authorization"] = $"Bearer {token}"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
private static string BuildSystemPrompt() =>
|
||||
"""
|
||||
You are a planning assistant for ClaudeDo.
|
||||
Your role is to help break down a task into smaller, actionable subtasks.
|
||||
|
||||
Use the available MCP tools (mcp__claudedo__*) to create child tasks.
|
||||
When you are done planning, finalize the session.
|
||||
|
||||
Be concise and focused. Each subtask should be independently executable.
|
||||
""";
|
||||
|
||||
private static string BuildInitialPrompt(TaskEntity task)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"# Task: {task.Title}");
|
||||
if (!string.IsNullOrWhiteSpace(task.Description))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(task.Description);
|
||||
}
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Please analyze this task and break it down into concrete subtasks.");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
40
src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs
Normal file
40
src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed class PlanningTokenAuthMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public PlanningTokenAuthMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx, TaskRepository tasks)
|
||||
{
|
||||
if (!ctx.Request.Path.StartsWithSegments("/mcp"))
|
||||
{
|
||||
await _next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
var auth = ctx.Request.Headers["Authorization"].ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ctx.Response.StatusCode = 401;
|
||||
await ctx.Response.WriteAsync("Missing bearer token");
|
||||
return;
|
||||
}
|
||||
|
||||
var token = auth.Substring("Bearer ".Length).Trim();
|
||||
var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted);
|
||||
if (parent is null || parent.Status != ClaudeDo.Data.Models.TaskStatus.Planning)
|
||||
{
|
||||
ctx.Response.StatusCode = 401;
|
||||
await ctx.Response.WriteAsync("Invalid or expired planning token");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
||||
await _next(ctx);
|
||||
}
|
||||
}
|
||||
125
src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
Normal file
125
src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
// Claude CLI flags (verified 2026-04-23 via Context7):
|
||||
// Thinking budget: env var MAX_THINKING_TOKENS=20000 (no CLI flag exists)
|
||||
// Allowed-tools: --allowedTools (camelCase), comma-separated tokens
|
||||
// System prompt: --append-system-prompt-file <path> (file form)
|
||||
// Session ID: no pre-assign flag; resume with --resume <id>
|
||||
// Launch model: wt.exe directly spawns claude.exe via argv (UseShellExecute=false).
|
||||
// No cmd /k shim — arbitrary initial-prompt content would be re-parsed by cmd.exe otherwise.
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher
|
||||
{
|
||||
private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill";
|
||||
private const string Model = "claude-sonnet-4-6";
|
||||
|
||||
private readonly string _wtPath;
|
||||
private readonly string _claudePath;
|
||||
|
||||
public WindowsTerminalPlanningLauncher(string wtPath, string claudePath)
|
||||
{
|
||||
_wtPath = wtPath;
|
||||
_claudePath = claudePath;
|
||||
}
|
||||
|
||||
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(ctx.WorkingDir))
|
||||
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
|
||||
|
||||
if (!File.Exists(ctx.Files.McpConfigPath))
|
||||
throw new PlanningLaunchException($"MCP config file not found: {ctx.Files.McpConfigPath}");
|
||||
|
||||
var resolvedWt = Resolve(_wtPath);
|
||||
if (resolvedWt is null)
|
||||
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
|
||||
|
||||
var resolvedClaude = Resolve(_claudePath);
|
||||
if (resolvedClaude is null)
|
||||
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = resolvedWt,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = false,
|
||||
};
|
||||
|
||||
psi.Environment["MAX_THINKING_TOKENS"] = "20000";
|
||||
|
||||
psi.ArgumentList.Add("-d");
|
||||
psi.ArgumentList.Add(ctx.WorkingDir);
|
||||
psi.ArgumentList.Add(resolvedClaude);
|
||||
psi.ArgumentList.Add("--model");
|
||||
psi.ArgumentList.Add(Model);
|
||||
psi.ArgumentList.Add("--mcp-config");
|
||||
psi.ArgumentList.Add(ctx.Files.McpConfigPath);
|
||||
psi.ArgumentList.Add("--append-system-prompt-file");
|
||||
psi.ArgumentList.Add(ctx.Files.SystemPromptPath);
|
||||
psi.ArgumentList.Add("--allowedTools");
|
||||
psi.ArgumentList.Add(AllowedTools);
|
||||
psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath));
|
||||
|
||||
var proc = Process.Start(psi)
|
||||
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(ctx.WorkingDir))
|
||||
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
|
||||
|
||||
var resolvedWt = Resolve(_wtPath);
|
||||
if (resolvedWt is null)
|
||||
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
|
||||
|
||||
var resolvedClaude = Resolve(_claudePath);
|
||||
if (resolvedClaude is null)
|
||||
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = resolvedWt,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = false,
|
||||
};
|
||||
|
||||
psi.ArgumentList.Add("-d");
|
||||
psi.ArgumentList.Add(ctx.WorkingDir);
|
||||
psi.ArgumentList.Add(resolvedClaude);
|
||||
psi.ArgumentList.Add("--resume");
|
||||
psi.ArgumentList.Add(ctx.ClaudeSessionId);
|
||||
psi.ArgumentList.Add("--mcp-config");
|
||||
psi.ArgumentList.Add(ctx.McpConfigPath);
|
||||
|
||||
var proc = Process.Start(psi)
|
||||
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string? Resolve(string pathOrName)
|
||||
{
|
||||
if (File.Exists(pathOrName))
|
||||
return pathOrName;
|
||||
|
||||
// Try PATH resolution
|
||||
var envPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
var extensions = new[] { "", ".exe", ".cmd", ".bat" };
|
||||
foreach (var dir in envPath.Split(Path.PathSeparator))
|
||||
{
|
||||
foreach (var ext in extensions)
|
||||
{
|
||||
var candidate = Path.Combine(dir, pathOrName + ext);
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -51,6 +52,25 @@ builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
|
||||
builder.Services.AddSingleton<QueueService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
|
||||
|
||||
// Planning session services.
|
||||
var planningSessionsDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
".todo-app", "planning-sessions");
|
||||
builder.Services.AddSingleton(sp =>
|
||||
new PlanningSessionManager(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
planningSessionsDir));
|
||||
builder.Services.AddSingleton<IPlanningTerminalLauncher, WindowsTerminalPlanningLauncher>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<PlanningMcpContextAccessor>();
|
||||
builder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||
builder.Services.AddScoped<TaskRepository>();
|
||||
builder.Services.AddScoped<PlanningMcpService>();
|
||||
builder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithTools<PlanningMcpService>();
|
||||
|
||||
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
||||
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
||||
|
||||
@@ -75,7 +95,9 @@ catch (Exception ex)
|
||||
app.Logger.LogWarning(ex, "Default agent seeding failed");
|
||||
}
|
||||
|
||||
app.UseMiddleware<PlanningTokenAuthMiddleware>();
|
||||
app.MapHub<WorkerHub>("/hub");
|
||||
app.MapMcp("/mcp");
|
||||
|
||||
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
||||
cfg.SignalRPort, cfg.DbPath);
|
||||
|
||||
221
tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs
Normal file
221
tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Services;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Xunit;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Hub;
|
||||
|
||||
public sealed class PlanningHubTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly string _rootDir;
|
||||
private readonly PlanningSessionManager _planning;
|
||||
private readonly FakePlanningLauncher _launcher;
|
||||
private readonly RecordingClientProxy _proxy;
|
||||
|
||||
public PlanningHubTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
_rootDir = Path.Combine(Path.GetTempPath(), $"cd_hub_planning_{Guid.NewGuid():N}");
|
||||
_planning = new PlanningSessionManager(_tasks, _lists, _rootDir);
|
||||
_launcher = new FakePlanningLauncher();
|
||||
_proxy = new RecordingClientProxy();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ctx.Dispose();
|
||||
_db.Dispose();
|
||||
try { Directory.Delete(_rootDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private WorkerHub CreateHub()
|
||||
{
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, null!, null!, null!, null!,
|
||||
_planning, _launcher);
|
||||
hub.Clients = new FakeHubCallerClients(_proxy);
|
||||
hub.Context = new FakeHubCallerContext();
|
||||
return hub;
|
||||
}
|
||||
|
||||
private async Task<(string listId, string taskId)> SeedAsync()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(wd);
|
||||
await _lists.AddAsync(new ListEntity
|
||||
{
|
||||
Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = "Do something",
|
||||
Status = TaskStatus.Manual,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "feat",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
return (listId, task.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher()
|
||||
{
|
||||
var (_, taskId) = await SeedAsync();
|
||||
var hub = CreateHub();
|
||||
|
||||
var ctx = await hub.StartPlanningSessionAsync(taskId);
|
||||
|
||||
Assert.Equal(taskId, ctx.ParentTaskId);
|
||||
Assert.Equal(1, _launcher.LaunchStartCalls);
|
||||
Assert.Equal(0, _launcher.LaunchResumeCalls);
|
||||
|
||||
var loaded = await _tasks.GetByIdAsync(taskId);
|
||||
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
||||
|
||||
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartPlanningSessionAsync_LauncherFails_Discards()
|
||||
{
|
||||
var (_, taskId) = await SeedAsync();
|
||||
_launcher.ShouldThrow = true;
|
||||
var hub = CreateHub();
|
||||
|
||||
await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
||||
hub.StartPlanningSessionAsync(taskId));
|
||||
|
||||
var loaded = await _tasks.GetByIdAsync(taskId);
|
||||
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
||||
|
||||
var sessionDir = Path.Combine(_rootDir, taskId);
|
||||
Assert.False(Directory.Exists(sessionDir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardPlanningSessionAsync_ResetsTask_AndBroadcasts()
|
||||
{
|
||||
var (_, taskId) = await SeedAsync();
|
||||
// Put task into Planning state first
|
||||
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||
_proxy.Sent.Clear();
|
||||
|
||||
var hub = CreateHub();
|
||||
await hub.DiscardPlanningSessionAsync(taskId);
|
||||
|
||||
var loaded = await _tasks.GetByIdAsync(taskId);
|
||||
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
||||
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalizePlanningSessionAsync_PromotesDraftsAndBroadcasts()
|
||||
{
|
||||
var (_, taskId) = await SeedAsync();
|
||||
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||
await _tasks.CreateChildAsync(taskId, "child 1", null, null, null);
|
||||
await _tasks.CreateChildAsync(taskId, "child 2", null, null, null);
|
||||
_proxy.Sent.Clear();
|
||||
|
||||
var hub = CreateHub();
|
||||
var count = await hub.FinalizePlanningSessionAsync(taskId, queueAgentTasks: false);
|
||||
|
||||
Assert.Equal(2, count);
|
||||
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingDraftCountAsync_ReturnsCount()
|
||||
{
|
||||
var (_, taskId) = await SeedAsync();
|
||||
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||
await _tasks.CreateChildAsync(taskId, "c1", null, null, null);
|
||||
await _tasks.CreateChildAsync(taskId, "c2", null, null, null);
|
||||
|
||||
var hub = CreateHub();
|
||||
var count = await hub.GetPendingDraftCountAsync(taskId);
|
||||
|
||||
Assert.Equal(2, count);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
|
||||
{
|
||||
public bool ShouldThrow { get; set; }
|
||||
public int LaunchStartCalls { get; private set; }
|
||||
public int LaunchResumeCalls { get; private set; }
|
||||
|
||||
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
|
||||
LaunchStartCalls++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
|
||||
{
|
||||
LaunchResumeCalls++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RecordingClientProxy : IClientProxy
|
||||
{
|
||||
public List<(string method, object?[] args)> Sent { get; } = new();
|
||||
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Sent.Add((method, args));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeHubCallerClients : IHubCallerClients
|
||||
{
|
||||
private readonly IClientProxy _all;
|
||||
public FakeHubCallerClients(IClientProxy proxy) => _all = proxy;
|
||||
|
||||
public IClientProxy All => _all;
|
||||
public IClientProxy Caller => _all;
|
||||
public IClientProxy Others => _all;
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => _all;
|
||||
public IClientProxy Client(string connectionId) => _all;
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => _all;
|
||||
public IClientProxy Group(string groupName) => _all;
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => _all;
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => _all;
|
||||
public IClientProxy OthersInGroup(string groupName) => _all;
|
||||
public IClientProxy User(string userId) => _all;
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => _all;
|
||||
}
|
||||
|
||||
internal sealed class FakeHubCallerContext : HubCallerContext
|
||||
{
|
||||
public override string ConnectionId => "test-conn";
|
||||
public override string? UserIdentifier => null;
|
||||
public override System.Security.Claims.ClaimsPrincipal? User => null;
|
||||
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
|
||||
public override Microsoft.AspNetCore.Http.Features.IFeatureCollection Features { get; } =
|
||||
new Microsoft.AspNetCore.Http.Features.FeatureCollection();
|
||||
public override CancellationToken ConnectionAborted => CancellationToken.None;
|
||||
public override void Abort() { }
|
||||
}
|
||||
@@ -10,9 +10,9 @@ public sealed class DbFixture : IDisposable
|
||||
public DbFixture()
|
||||
{
|
||||
DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db");
|
||||
// Apply migrations so the schema is created.
|
||||
// EnsureCreated uses the current model directly — no Designer.cs needed.
|
||||
using var ctx = CreateContext();
|
||||
ctx.Database.Migrate();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public ClaudeDoDbContext CreateContext()
|
||||
|
||||
109
tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs
Normal file
109
tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Planning;
|
||||
|
||||
// Inline fakes — test isolation beats DRY; mirrors PlanningMcpServiceTests pattern.
|
||||
file sealed class E2EFakeHttpContextAccessor : IHttpContextAccessor
|
||||
{
|
||||
public HttpContext? HttpContext { get; set; }
|
||||
}
|
||||
|
||||
file sealed class E2ENullHubClients : IHubClients
|
||||
{
|
||||
public IClientProxy All => E2ENullClientProxy.Instance;
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => E2ENullClientProxy.Instance;
|
||||
public IClientProxy Client(string connectionId) => E2ENullClientProxy.Instance;
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => E2ENullClientProxy.Instance;
|
||||
public IClientProxy Group(string groupName) => E2ENullClientProxy.Instance;
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => E2ENullClientProxy.Instance;
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => E2ENullClientProxy.Instance;
|
||||
public IClientProxy User(string userId) => E2ENullClientProxy.Instance;
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => E2ENullClientProxy.Instance;
|
||||
}
|
||||
|
||||
file sealed class E2ENullClientProxy : IClientProxy
|
||||
{
|
||||
public static readonly E2ENullClientProxy Instance = new();
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
file sealed class E2EFakeHubContext : IHubContext<WorkerHub>
|
||||
{
|
||||
public IHubClients Clients { get; } = new E2ENullHubClients();
|
||||
public IGroupManager Groups => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public sealed class PlanningEndToEndTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly PlanningSessionManager _manager;
|
||||
private readonly DefaultHttpContext _httpContext;
|
||||
private readonly PlanningMcpContextAccessor _accessor;
|
||||
private readonly PlanningMcpService _svc;
|
||||
|
||||
public PlanningEndToEndTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
|
||||
var root = Path.Combine(Path.GetTempPath(), $"cd_e2e_{Guid.NewGuid():N}");
|
||||
_manager = new PlanningSessionManager(_tasks, _lists, root);
|
||||
|
||||
_httpContext = new DefaultHttpContext();
|
||||
_accessor = new PlanningMcpContextAccessor(new E2EFakeHttpContextAccessor { HttpContext = _httpContext });
|
||||
var broadcaster = new HubBroadcaster(new E2EFakeHubContext());
|
||||
_svc = new PlanningMcpService(_tasks, _accessor, broadcaster);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
[Fact]
|
||||
public async Task StartThenCreateThenFinalize_FullFlow()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var wd = Path.GetTempPath();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
|
||||
|
||||
var parent = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = "Big Task",
|
||||
Status = TaskStatus.Manual,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(parent);
|
||||
|
||||
var startCtx = await _manager.StartAsync(parent.Id, CancellationToken.None);
|
||||
Assert.True(File.Exists(startCtx.Files.McpConfigPath));
|
||||
|
||||
// Wire the ambient context so _svc reads the correct parent
|
||||
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
||||
|
||||
await _svc.CreateChildTask("sub 1", null, null, null, CancellationToken.None);
|
||||
await _svc.CreateChildTask("sub 2", null, null, null, CancellationToken.None);
|
||||
|
||||
var count = await _svc.Finalize(true, CancellationToken.None);
|
||||
Assert.Equal(2, count);
|
||||
|
||||
var reload = await _tasks.GetByIdAsync(parent.Id);
|
||||
Assert.Equal(TaskStatus.Planned, reload!.Status);
|
||||
|
||||
var kids = await _tasks.GetChildrenAsync(parent.Id);
|
||||
Assert.All(kids, k => Assert.Equal(TaskStatus.Manual, k.Status));
|
||||
}
|
||||
}
|
||||
181
tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs
Normal file
181
tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Planning;
|
||||
|
||||
// Minimal fakes — avoids Moq dependency.
|
||||
file sealed class FakeHttpContextAccessor : IHttpContextAccessor
|
||||
{
|
||||
public HttpContext? HttpContext { get; set; }
|
||||
}
|
||||
|
||||
file sealed class NullHubClients : IHubClients
|
||||
{
|
||||
public IClientProxy All => NullClientProxy.Instance;
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => NullClientProxy.Instance;
|
||||
public IClientProxy Client(string connectionId) => NullClientProxy.Instance;
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => NullClientProxy.Instance;
|
||||
public IClientProxy Group(string groupName) => NullClientProxy.Instance;
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => NullClientProxy.Instance;
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => NullClientProxy.Instance;
|
||||
public IClientProxy User(string userId) => NullClientProxy.Instance;
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => NullClientProxy.Instance;
|
||||
}
|
||||
|
||||
file sealed class NullClientProxy : IClientProxy
|
||||
{
|
||||
public static readonly NullClientProxy Instance = new();
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
file sealed class FakeHubContext : IHubContext<WorkerHub>
|
||||
{
|
||||
public IHubClients Clients { get; } = new NullHubClients();
|
||||
public IGroupManager Groups => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public sealed class PlanningMcpServiceTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
|
||||
public PlanningMcpServiceTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private PlanningMcpService BuildSut(string parentTaskId)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parentTaskId };
|
||||
var accessor = new PlanningMcpContextAccessor(new FakeHttpContextAccessor { HttpContext = httpContext });
|
||||
var broadcaster = new HubBroadcaster(new FakeHubContext());
|
||||
return new PlanningMcpService(_tasks, accessor, broadcaster);
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedPlanningParentAsync()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
var parent = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = "p",
|
||||
Status = TaskStatus.Manual,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(parent);
|
||||
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||
return (await _tasks.GetByIdAsync(parent.Id))!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateChildTask_CreatesDraft()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
var sut = BuildSut(parent.Id);
|
||||
|
||||
var result = await sut.CreateChildTask("My child", "desc", null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("Draft", result.Status);
|
||||
var child = await _tasks.GetByIdAsync(result.TaskId);
|
||||
Assert.Equal("My child", child!.Title);
|
||||
Assert.Equal(TaskStatus.Draft, child.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListChildTasks_ReturnsOnlyThisParentsChildren()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
var other = await SeedPlanningParentAsync();
|
||||
|
||||
await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null);
|
||||
await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null);
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
var list = await sut.ListChildTasks(CancellationToken.None);
|
||||
Assert.Single(list);
|
||||
Assert.Equal("mine", list[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateChildTask_NotAChild_Throws()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
var other = await SeedPlanningParentAsync();
|
||||
var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null);
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateChildTask(otherChild.Id, "new", null, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateChildTask_NotDraft_Throws()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false);
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateChildTask(c.Id, "new", null, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteChildTask_RemovesDraft()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
await sut.DeleteChildTask(c.Id, CancellationToken.None);
|
||||
|
||||
Assert.Null(await _tasks.GetByIdAsync(c.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePlanningTask_SetsTitleAndDescription()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
await sut.UpdatePlanningTask("new title", "new desc", CancellationToken.None);
|
||||
|
||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||
Assert.Equal("new title", loaded!.Title);
|
||||
Assert.Equal("new desc", loaded.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Finalize_PromotesDraftsAndInvalidatesToken()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
var count = await sut.Finalize(true, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, count);
|
||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
||||
Assert.Null(loaded.PlanningSessionToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Planning;
|
||||
|
||||
public sealed class PlanningSessionManagerTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly string _rootDir;
|
||||
private readonly PlanningSessionManager _sut;
|
||||
|
||||
public PlanningSessionManagerTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
_rootDir = Path.Combine(Path.GetTempPath(), $"cd_planning_{Guid.NewGuid():N}");
|
||||
_sut = new PlanningSessionManager(_tasks, _lists, _rootDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ctx.Dispose();
|
||||
_db.Dispose();
|
||||
try { Directory.Delete(_rootDir, recursive: true); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private async Task<(string listId, string workingDir)> SeedListAsync()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(wd);
|
||||
await _lists.AddAsync(new ListEntity
|
||||
{
|
||||
Id = listId,
|
||||
Name = "Test",
|
||||
WorkingDir = wd,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
return (listId, wd);
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedManualTaskAsync(string listId)
|
||||
{
|
||||
var t = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = "Brainstorm auth",
|
||||
Description = "- review tokens\n- plan rollout",
|
||||
Status = TaskStatus.Manual,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "feat",
|
||||
};
|
||||
await _tasks.AddAsync(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_CreatesSessionFiles_AndTransitionsTaskToPlanning()
|
||||
{
|
||||
var (listId, wd) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
|
||||
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(parent.Id, ctx.ParentTaskId);
|
||||
Assert.Equal(wd, ctx.WorkingDir);
|
||||
Assert.True(File.Exists(ctx.Files.McpConfigPath));
|
||||
Assert.True(File.Exists(ctx.Files.SystemPromptPath));
|
||||
Assert.True(File.Exists(ctx.Files.InitialPromptPath));
|
||||
|
||||
var mcp = await File.ReadAllTextAsync(ctx.Files.McpConfigPath);
|
||||
Assert.Contains("\"type\": \"http\"", mcp);
|
||||
Assert.Contains("Bearer ", mcp);
|
||||
|
||||
var initial = await File.ReadAllTextAsync(ctx.Files.InitialPromptPath);
|
||||
Assert.Contains("Brainstorm auth", initial);
|
||||
Assert.Contains("review tokens", initial);
|
||||
|
||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
||||
Assert.NotNull(loaded.PlanningSessionToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_TaskNotManual_Throws()
|
||||
{
|
||||
var (listId, _) = await SeedListAsync();
|
||||
var queuedTask = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = "x",
|
||||
Status = TaskStatus.Queued,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "feat",
|
||||
};
|
||||
await _tasks.AddAsync(queuedTask);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.StartAsync(queuedTask.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_ChildTask_Throws()
|
||||
{
|
||||
var (listId, _) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
await _tasks.SetPlanningStartedAsync(parent.Id, "t");
|
||||
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.StartAsync(child.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeAsync_ReturnsExistingSessionDetails()
|
||||
{
|
||||
var (listId, wd) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-session-42");
|
||||
|
||||
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(parent.Id, resumeCtx.ParentTaskId);
|
||||
Assert.Equal(wd, resumeCtx.WorkingDir);
|
||||
Assert.Equal("claude-session-42", resumeCtx.ClaudeSessionId);
|
||||
Assert.Equal(startCtx.Files.McpConfigPath, resumeCtx.McpConfigPath);
|
||||
Assert.True(File.Exists(resumeCtx.McpConfigPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeAsync_NotPlanning_Throws()
|
||||
{
|
||||
var (listId, _) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
// did not start
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.ResumeAsync(parent.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeAsync_NoClaudeSessionId_Throws()
|
||||
{
|
||||
var (listId, _) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
// UpdatePlanningSessionIdAsync not called
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.ResumeAsync(parent.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalizeAsync_PromotesDraftsAndMarksPlanned()
|
||||
{
|
||||
var (listId, _) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||
|
||||
var count = await _sut.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, count);
|
||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingDraftCountAsync_ReturnsDraftCount()
|
||||
{
|
||||
var (listId, _) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c3", null, null, null);
|
||||
|
||||
var n = await _sut.GetPendingDraftCountAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(3, n);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardAsync_DeletesSessionDirAndResetsTask()
|
||||
{
|
||||
var (listId, _) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
Assert.True(Directory.Exists(startCtx.Files.SessionDirectory));
|
||||
|
||||
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
Assert.False(Directory.Exists(startCtx.Files.SessionDirectory));
|
||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
||||
Assert.Null(loaded.PlanningSessionToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using ClaudeDo.Worker.Planning;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Planning;
|
||||
|
||||
public sealed class WindowsTerminalPlanningLauncherTests
|
||||
{
|
||||
private static PlanningSessionStartContext MakeStartCtx(string? wd = null)
|
||||
{
|
||||
var workingDir = wd ?? Path.GetTempPath();
|
||||
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(dir);
|
||||
return new PlanningSessionStartContext(
|
||||
ParentTaskId: "task-1",
|
||||
WorkingDir: workingDir,
|
||||
Files: new PlanningSessionFiles(
|
||||
SessionDirectory: dir,
|
||||
McpConfigPath: Path.Combine(dir, "mcp.json"),
|
||||
SystemPromptPath: Path.Combine(dir, "system-prompt.md"),
|
||||
InitialPromptPath: Path.Combine(dir, "initial-prompt.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LaunchStartAsync_WorkingDirMissing_Throws()
|
||||
{
|
||||
var ctx = MakeStartCtx(wd: Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid()));
|
||||
var sut = new WindowsTerminalPlanningLauncher(wtPath: "wt", claudePath: "claude");
|
||||
var ex = await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
||||
sut.LaunchStartAsync(ctx, CancellationToken.None));
|
||||
Assert.Contains("Working directory", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LaunchStartAsync_WtMissing_Throws()
|
||||
{
|
||||
var ctx = MakeStartCtx();
|
||||
File.WriteAllText(ctx.Files.McpConfigPath, "{}");
|
||||
File.WriteAllText(ctx.Files.SystemPromptPath, "sp");
|
||||
File.WriteAllText(ctx.Files.InitialPromptPath, "ip");
|
||||
|
||||
var sut = new WindowsTerminalPlanningLauncher(
|
||||
wtPath: "C:/no/such/wt.exe",
|
||||
claudePath: "claude");
|
||||
var ex = await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
||||
sut.LaunchStartAsync(ctx, CancellationToken.None));
|
||||
Assert.Contains("Windows Terminal", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user