Compare commits
29 Commits
ee2cbc92ef
...
feat/plann
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09b52140ce | ||
|
|
e7d595244e | ||
| 993851009b | |||
|
|
450e685580 | ||
|
|
0e116bec7b | ||
|
|
47b49743c0 | ||
|
|
506caa2c53 | ||
|
|
388a8c1fae | ||
|
|
42b208ff28 | ||
|
|
309f84b388 | ||
|
|
00608401aa | ||
|
|
229d4bbb2b | ||
| 845359b885 | |||
|
|
d4a46420c9 | ||
|
|
f704244b84 | ||
|
|
782110604b | ||
|
|
19bf032a2e | ||
|
|
b7464c9a11 | ||
|
|
524aaf85af | ||
|
|
a9e7479326 | ||
|
|
2e80cc606e | ||
|
|
d099138487 | ||
|
|
2278d97b7e | ||
|
|
74255ddc82 | ||
|
|
b466246c1b | ||
|
|
b3eb39a28b | ||
|
|
253e6f05e0 | ||
|
|
042a1b47c2 | ||
|
|
7a20534e7c |
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.
|
7. Repeat step 6 with **Continue anyway** → wizard opens without self-update.
|
||||||
8. Repeat step 6 with **Cancel** → installer exits without any action.
|
8. Repeat step 6 with **Cancel** → installer exits without any action.
|
||||||
9. Kill network during startup in both app and installer → confirm silent fallback (no errors, no banner, wizard opens normally).
|
9. Kill network during startup in both app and installer → confirm silent fallback (no errors, no banner, wizard opens normally).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning Sessions — Manual Verification (Plan C UI)
|
||||||
|
|
||||||
|
Requires Plan B (worker hub endpoints) merged. Until then, only the UI structure/styling checks are meaningful.
|
||||||
|
|
||||||
|
1. Create a Manual task with a title and a TODO-ish description.
|
||||||
|
2. Right-click the task → **Open planning Session** — Windows Terminal opens with Claude CLI running (Plan B).
|
||||||
|
3. Ask Claude to create two child tasks via `mcp__claudedo__create_child_task`.
|
||||||
|
4. Watch the UI: drafts appear indented under the parent, italic, reduced opacity, with a `DRAFT` badge.
|
||||||
|
5. The parent shows a `PLANNING` badge. Click the chevron → children collapse; click again → children expand.
|
||||||
|
6. Ask Claude to `finalize` — drafts flip to Manual/Queued children; parent flips to `PLANNED` badge.
|
||||||
|
7. In a new planning task, close the terminal without finalize. Right-click the Planning task → the unfinished-session modal opens with Resume / Finalize now / Discard.
|
||||||
|
8. Attempt to delete a parent with children via the details panel — confirm the friendly error dialog appears and the task is NOT deleted.
|
||||||
|
|
||||||
|
**Known followups (non-blocking):**
|
||||||
|
- `Border.badge.planned` style (blue) is defined in `IslandStyles.axaml` but never applied — `TaskRowView` keeps the `planning` class for both Planning and Planned, so Planned gets the amber badge. Either make the view swap `Classes.planned` when status is Planned, or remove the unused style + brush.
|
||||||
|
- Dead `Instance` statics on `BoolToItalicConverter` and `BoolToDraftOpacityConverter` — App.axaml registers instances via the resource dictionary; the static members can be removed.
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
<converters:EqStatusConverter x:Key="EqStatus"/>
|
<converters:EqStatusConverter x:Key="EqStatus"/>
|
||||||
<converters:UpperCaseConverter x:Key="UpperCase"/>
|
<converters:UpperCaseConverter x:Key="UpperCase"/>
|
||||||
<converters:IconKeyConverter x:Key="IconKey"/>
|
<converters:IconKeyConverter x:Key="IconKey"/>
|
||||||
<converters:DotBrushConverter x:Key="DotBrush"/>
|
<converters:DotBrushConverter x:Key="DotBrush"/>
|
||||||
|
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
|
||||||
|
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ public class ClaudeDoDbContext : DbContext
|
|||||||
walCmd.ExecuteNonQuery();
|
walCmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable FK enforcement — SQLite defaults to OFF per connection.
|
||||||
|
using (var fkCmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
fkCmd.CommandText = "PRAGMA foreign_keys=ON;";
|
||||||
|
fkCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
// If the 'lists' table exists but __EFMigrationsHistory does not,
|
// If the 'lists' table exists but __EFMigrationsHistory does not,
|
||||||
// this is a pre-EF database. Baseline the InitialCreate migration.
|
// this is a pre-EF database. Baseline the InitialCreate migration.
|
||||||
using (var cmd = conn.CreateCommand())
|
using (var cmd = conn.CreateCommand())
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
: v == TaskStatus.Running ? "running"
|
: v == TaskStatus.Running ? "running"
|
||||||
: v == TaskStatus.Done ? "done"
|
: v == TaskStatus.Done ? "done"
|
||||||
: v == TaskStatus.Failed ? "failed"
|
: v == TaskStatus.Failed ? "failed"
|
||||||
|
: v == TaskStatus.Planning ? "planning"
|
||||||
|
: v == TaskStatus.Planned ? "planned"
|
||||||
|
: v == TaskStatus.Draft ? "draft"
|
||||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||||
|
|
||||||
private static TaskStatus StatusFromString(string v)
|
private static TaskStatus StatusFromString(string v)
|
||||||
@@ -22,6 +25,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
: v == "running" ? TaskStatus.Running
|
: v == "running" ? TaskStatus.Running
|
||||||
: v == "done" ? TaskStatus.Done
|
: v == "done" ? TaskStatus.Done
|
||||||
: v == "failed" ? TaskStatus.Failed
|
: v == "failed" ? TaskStatus.Failed
|
||||||
|
: v == "planning" ? TaskStatus.Planning
|
||||||
|
: v == "planned" ? TaskStatus.Planned
|
||||||
|
: v == "draft" ? TaskStatus.Draft
|
||||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||||
|
|
||||||
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
|
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
|
||||||
@@ -53,6 +59,16 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
builder.Property(t => t.Notes).HasColumnName("notes");
|
builder.Property(t => t.Notes).HasColumnName("notes");
|
||||||
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||||
|
|
||||||
|
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
|
||||||
|
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
|
||||||
|
builder.Property(t => t.PlanningSessionToken).HasColumnName("planning_session_token");
|
||||||
|
builder.Property(t => t.PlanningFinalizedAt).HasColumnName("planning_finalized_at");
|
||||||
|
|
||||||
|
builder.HasOne(t => t.Parent)
|
||||||
|
.WithMany(t => t.Children)
|
||||||
|
.HasForeignKey(t => t.ParentTaskId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
builder.HasOne(t => t.List)
|
builder.HasOne(t => t.List)
|
||||||
.WithMany(l => l.Tasks)
|
.WithMany(l => l.Tasks)
|
||||||
.HasForeignKey(t => t.ListId)
|
.HasForeignKey(t => t.ListId)
|
||||||
@@ -76,5 +92,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
||||||
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
||||||
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
|
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
|
||||||
|
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPlanningSupport : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "parent_task_id",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "planning_finalized_at",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "planning_session_id",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "planning_session_token",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_tasks_parent_task_id",
|
||||||
|
table: "tasks",
|
||||||
|
column: "parent_task_id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_tasks_tasks_parent_task_id",
|
||||||
|
table: "tasks",
|
||||||
|
column: "parent_task_id",
|
||||||
|
principalTable: "tasks",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_tasks_tasks_parent_task_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "idx_tasks_parent_task_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "parent_task_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "planning_finalized_at",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "planning_session_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "planning_session_token",
|
||||||
|
table: "tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -273,6 +273,22 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("notes");
|
.HasColumnName("notes");
|
||||||
|
|
||||||
|
b.Property<string>("ParentTaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("parent_task_id");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_finalized_at");
|
||||||
|
|
||||||
|
b.Property<string>("PlanningSessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_session_id");
|
||||||
|
|
||||||
|
b.Property<string>("PlanningSessionToken")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_session_token");
|
||||||
|
|
||||||
b.Property<string>("Result")
|
b.Property<string>("Result")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("result");
|
.HasColumnName("result");
|
||||||
@@ -310,6 +326,9 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
b.HasIndex("ListId")
|
b.HasIndex("ListId")
|
||||||
.HasDatabaseName("idx_tasks_list_id");
|
.HasDatabaseName("idx_tasks_list_id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId")
|
||||||
|
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||||
|
|
||||||
b.HasIndex("Status")
|
b.HasIndex("Status")
|
||||||
.HasDatabaseName("idx_tasks_status");
|
.HasDatabaseName("idx_tasks_status");
|
||||||
|
|
||||||
@@ -502,7 +521,14 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||||
|
.WithMany("Children")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.Navigation("List");
|
b.Navigation("List");
|
||||||
|
|
||||||
|
b.Navigation("Parent");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||||
@@ -566,6 +592,8 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("Children");
|
||||||
|
|
||||||
b.Navigation("Runs");
|
b.Navigation("Runs");
|
||||||
|
|
||||||
b.Navigation("Subtasks");
|
b.Navigation("Subtasks");
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ public enum TaskStatus
|
|||||||
Running,
|
Running,
|
||||||
Done,
|
Done,
|
||||||
Failed,
|
Failed,
|
||||||
|
Planning,
|
||||||
|
Planned,
|
||||||
|
Draft,
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TaskEntity
|
public sealed class TaskEntity
|
||||||
@@ -31,10 +34,18 @@ public sealed class TaskEntity
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
public string? ParentTaskId { get; set; }
|
||||||
|
public string? PlanningSessionId { get; set; }
|
||||||
|
public string? PlanningSessionToken { get; set; }
|
||||||
|
public DateTime? PlanningFinalizedAt { get; set; }
|
||||||
|
|
||||||
// Navigation properties
|
// Navigation properties
|
||||||
public ListEntity List { get; set; } = null!;
|
public ListEntity List { get; set; } = null!;
|
||||||
public WorktreeEntity? Worktree { get; set; }
|
public WorktreeEntity? Worktree { get; set; }
|
||||||
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
|
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
|
||||||
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
|
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
|
||||||
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
|
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
|
||||||
|
|
||||||
|
public TaskEntity? Parent { get; set; }
|
||||||
|
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,206 @@ public sealed class TaskRepository
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Planning
|
||||||
|
|
||||||
|
public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskEntity> CreateChildAsync(
|
||||||
|
string parentId,
|
||||||
|
string title,
|
||||||
|
string? description,
|
||||||
|
IReadOnlyList<string>? tagNames,
|
||||||
|
string? commitType,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null)
|
||||||
|
throw new InvalidOperationException($"Parent task {parentId} not found.");
|
||||||
|
|
||||||
|
var maxSort = await _context.Tasks
|
||||||
|
.Where(t => t.ListId == parent.ListId)
|
||||||
|
.Select(t => (int?)t.SortOrder)
|
||||||
|
.MaxAsync(ct);
|
||||||
|
|
||||||
|
var child = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = parent.ListId,
|
||||||
|
Title = title,
|
||||||
|
Description = description,
|
||||||
|
Status = TaskStatus.Draft,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType,
|
||||||
|
ParentTaskId = parentId,
|
||||||
|
SortOrder = (maxSort ?? -1) + 1,
|
||||||
|
};
|
||||||
|
_context.Tasks.Add(child);
|
||||||
|
|
||||||
|
if (tagNames is not null && tagNames.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var tagName in tagNames.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == tagName, ct);
|
||||||
|
if (tag is null)
|
||||||
|
{
|
||||||
|
tag = new TagEntity { Name = tagName };
|
||||||
|
_context.Tags.Add(tag);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
child.Tags.Add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskEntity?> SetPlanningStartedAsync(
|
||||||
|
string taskId,
|
||||||
|
string sessionToken,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var affected = await _context.Tasks
|
||||||
|
.Where(t => t.Id == taskId && t.Status == TaskStatus.Manual)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Planning)
|
||||||
|
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
|
||||||
|
|
||||||
|
if (affected == 0) return null;
|
||||||
|
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdatePlanningSessionIdAsync(
|
||||||
|
string parentId,
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.PlanningSessionId, sessionId), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskEntity?> FindByPlanningTokenAsync(
|
||||||
|
string token,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
return await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> FinalizePlanningAsync(
|
||||||
|
string parentId,
|
||||||
|
bool queueAgentTasks,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var parent = await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(t => t.List).ThenInclude(l => l.Tags)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null || parent.Status != TaskStatus.Planning)
|
||||||
|
throw new InvalidOperationException($"Task {parentId} is not in Planning state.");
|
||||||
|
|
||||||
|
var listHasAgentTag = parent.List.Tags.Any(t => t.Name == "agent");
|
||||||
|
|
||||||
|
var drafts = await _context.Tasks
|
||||||
|
.Include(t => t.Tags)
|
||||||
|
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
foreach (var draft in drafts)
|
||||||
|
{
|
||||||
|
var childHasAgentTag = draft.Tags.Any(t => t.Name == "agent");
|
||||||
|
var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag);
|
||||||
|
draft.Status = shouldQueue ? TaskStatus.Queued : TaskStatus.Manual;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalizedAt = DateTime.UtcNow;
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Planned)
|
||||||
|
.SetProperty(t => t.PlanningFinalizedAt, finalizedAt)
|
||||||
|
.SetProperty(t => t.PlanningSessionToken, (string?)null), ct);
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DiscardPlanningAsync(
|
||||||
|
string parentId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var parent = await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null || parent.Status != TaskStatus.Planning)
|
||||||
|
{
|
||||||
|
await tx.RollbackAsync(ct);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Manual)
|
||||||
|
.SetProperty(t => t.PlanningSessionId, (string?)null)
|
||||||
|
.SetProperty(t => t.PlanningSessionToken, (string?)null)
|
||||||
|
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
|
||||||
|
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TryCompleteParentAsync(
|
||||||
|
string parentId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null || parent.Status != TaskStatus.Planned) return;
|
||||||
|
|
||||||
|
var children = await _context.Tasks
|
||||||
|
.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.Select(t => t.Status)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (children.Count == 0) return;
|
||||||
|
|
||||||
|
bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed);
|
||||||
|
if (!allTerminal) return;
|
||||||
|
|
||||||
|
bool anyFailed = children.Any(s => s == TaskStatus.Failed);
|
||||||
|
var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done;
|
||||||
|
var finishedAt = DateTime.UtcNow;
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, finalStatus)
|
||||||
|
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region Queue selection
|
#region Queue selection
|
||||||
|
|
||||||
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="ClaudeDo.Ui.Tests" />
|
<InternalsVisibleTo Include="ClaudeDo.Ui.Tests" />
|
||||||
|
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
13
src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs
Normal file
13
src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class BoolToDraftOpacityConverter : IValueConverter
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
14
src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs
Normal file
14
src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class BoolToItalicConverter : IValueConverter
|
||||||
|
{
|
||||||
|
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) -->
|
<!-- Icon.Settings (gear) -->
|
||||||
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
|
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Badge brushes -->
|
||||||
|
<SolidColorBrush x:Key="DraftBadgeBrush" Color="#4A5568"/>
|
||||||
|
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="#D69E2E"/>
|
||||||
|
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="#3182CE"/>
|
||||||
|
|
||||||
</Styles.Resources>
|
</Styles.Resources>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
@@ -866,4 +871,31 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- PLANNING / DRAFT BADGES -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.badge">
|
||||||
|
<Setter Property="CornerRadius" Value="3"/>
|
||||||
|
<Setter Property="Padding" Value="4,1"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge > TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="9"/>
|
||||||
|
<Setter Property="FontWeight" Value="Bold"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.draft">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.planning">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.planned">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
</Styles>
|
</Styles>
|
||||||
|
|||||||
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)];
|
_delays[Math.Min(retryContext.PreviousRetryCount, _delays.Length - 1)];
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerClient
|
||||||
{
|
{
|
||||||
private readonly HubConnection _hub;
|
private readonly HubConnection _hub;
|
||||||
private CancellationTokenSource? _startCts;
|
private CancellationTokenSource? _startCts;
|
||||||
@@ -347,6 +347,33 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
|
||||||
|
|
||||||
|
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
|
||||||
|
|
||||||
|
// IWorkerClient explicit implementations (drop typed return values)
|
||||||
|
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||||
|
=> await StartPlanningSessionAsync(taskId, ct);
|
||||||
|
async Task IWorkerClient.ResumePlanningSessionAsync(string taskId, CancellationToken ct)
|
||||||
|
=> await ResumePlanningSessionAsync(taskId, ct);
|
||||||
|
async Task IWorkerClient.DiscardPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||||
|
=> await DiscardPlanningSessionAsync(taskId, ct);
|
||||||
|
async Task IWorkerClient.FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
||||||
|
=> await FinalizePlanningSessionAsync(taskId, queueAgentTasks, ct);
|
||||||
|
async Task<int> IWorkerClient.GetPendingDraftCountAsync(string taskId, CancellationToken ct)
|
||||||
|
=> await GetPendingDraftCountAsync(taskId, ct);
|
||||||
|
|
||||||
// DTOs for deserializing hub responses
|
// DTOs for deserializing hub responses
|
||||||
private sealed class ActiveTaskDto
|
private sealed class ActiveTaskDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
||||||
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
||||||
|
|
||||||
|
// Set by the view so DeleteTaskCommand can show an error message
|
||||||
|
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
||||||
|
|
||||||
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker, IServiceProvider services)
|
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker, IServiceProvider services)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -537,9 +540,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
|
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
await using var ctx = _dbFactory.CreateDbContext();
|
try
|
||||||
var repo = new TaskRepository(ctx);
|
{
|
||||||
await repo.DeleteAsync(row.Id);
|
await using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var repo = new TaskRepository(ctx);
|
||||||
|
await repo.DeleteAsync(row.Id);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (
|
||||||
|
ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
if (ShowErrorAsync != null)
|
||||||
|
await ShowErrorAsync("This task has child tasks. Discard the planning session or delete child tasks first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (DeleteFromList != null)
|
if (DeleteFromList != null)
|
||||||
await DeleteFromList(row);
|
await DeleteFromList(row);
|
||||||
CloseDetail?.Invoke();
|
CloseDetail?.Invoke();
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private int _diffDeletions;
|
[ObservableProperty] private int _diffDeletions;
|
||||||
[ObservableProperty] private bool _dropHintAbove;
|
[ObservableProperty] private bool _dropHintAbove;
|
||||||
[ObservableProperty] private bool _dropHintBelow;
|
[ObservableProperty] private bool _dropHintBelow;
|
||||||
|
[ObservableProperty] private string? _parentTaskId;
|
||||||
|
[ObservableProperty] private bool _isExpanded = true;
|
||||||
|
|
||||||
public DateTime CreatedAt { get; init; }
|
public DateTime CreatedAt { get; init; }
|
||||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
||||||
@@ -31,6 +33,22 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public int StepsCount { get; init; }
|
public int StepsCount { get; init; }
|
||||||
public int StepsCompleted { get; init; }
|
public int StepsCompleted { get; init; }
|
||||||
|
|
||||||
|
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||||||
|
public bool IsPlanningParent => Status == TaskStatus.Planning || Status == TaskStatus.Planned;
|
||||||
|
public bool IsPlanning => Status == TaskStatus.Planning;
|
||||||
|
public bool IsPlanned => Status == TaskStatus.Planned;
|
||||||
|
public bool IsDraft => Status == TaskStatus.Draft;
|
||||||
|
|
||||||
|
public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
|
||||||
|
public bool CanResumeOrDiscardPlanning => Status == TaskStatus.Planning;
|
||||||
|
|
||||||
|
public string? PlanningBadge => Status switch
|
||||||
|
{
|
||||||
|
TaskStatus.Planning => "PLANNING",
|
||||||
|
TaskStatus.Planned => "PLANNED",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
|
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
|
||||||
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
|
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
|
||||||
public bool HasTags => Tags.Count > 0;
|
public bool HasTags => Tags.Count > 0;
|
||||||
@@ -60,6 +78,19 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(IsRunning));
|
OnPropertyChanged(nameof(IsRunning));
|
||||||
OnPropertyChanged(nameof(IsQueued));
|
OnPropertyChanged(nameof(IsQueued));
|
||||||
OnPropertyChanged(nameof(HasLiveTail));
|
OnPropertyChanged(nameof(HasLiveTail));
|
||||||
|
OnPropertyChanged(nameof(IsPlanningParent));
|
||||||
|
OnPropertyChanged(nameof(IsPlanning));
|
||||||
|
OnPropertyChanged(nameof(IsPlanned));
|
||||||
|
OnPropertyChanged(nameof(PlanningBadge));
|
||||||
|
OnPropertyChanged(nameof(IsDraft));
|
||||||
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
|
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnParentTaskIdChanged(string? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsChild));
|
||||||
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||||
@@ -91,6 +122,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
DiffAdditions = add,
|
DiffAdditions = add,
|
||||||
DiffDeletions = del,
|
DiffDeletions = del,
|
||||||
CreatedAt = t.CreatedAt,
|
CreatedAt = t.CreatedAt,
|
||||||
|
ParentTaskId = t.ParentTaskId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
@@ -13,7 +14,8 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
|||||||
public sealed partial class TasksIslandViewModel : ViewModelBase
|
public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly WorkerClient? _worker;
|
private readonly IWorkerClient? _worker;
|
||||||
|
private readonly Dictionary<string, bool> _expandedState = new();
|
||||||
private ListNavItemViewModel? _currentList;
|
private ListNavItemViewModel? _currentList;
|
||||||
private CancellationTokenSource? _loadCts;
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
@@ -41,7 +43,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _showOpenLabel;
|
[ObservableProperty] private bool _showOpenLabel;
|
||||||
[ObservableProperty] private string _completedHeader = "COMPLETED";
|
[ObservableProperty] private string _completedHeader = "COMPLETED";
|
||||||
|
|
||||||
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient? worker = null)
|
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
|
||||||
|
|
||||||
|
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
@@ -105,14 +109,35 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Regroup()
|
internal void Regroup()
|
||||||
{
|
{
|
||||||
OverdueItems.Clear();
|
OverdueItems.Clear();
|
||||||
OpenItems.Clear();
|
OpenItems.Clear();
|
||||||
CompletedItems.Clear();
|
CompletedItems.Clear();
|
||||||
|
|
||||||
var today = DateTime.Today;
|
// Restore IsExpanded from saved state
|
||||||
foreach (var r in Items)
|
foreach (var r in Items)
|
||||||
|
{
|
||||||
|
if (_expandedState.TryGetValue(r.Id, out var saved))
|
||||||
|
r.IsExpanded = saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build hierarchy-aware flat list: top-level rows interleaved with visible children.
|
||||||
|
// Items is already ordered by SortOrder from the DB query.
|
||||||
|
var topLevel = Items.Where(r => !r.IsChild);
|
||||||
|
var flat = new List<TaskRowViewModel>();
|
||||||
|
foreach (var parent in topLevel)
|
||||||
|
{
|
||||||
|
flat.Add(parent);
|
||||||
|
if (parent.IsPlanningParent && parent.IsExpanded)
|
||||||
|
{
|
||||||
|
var children = Items.Where(r => r.ParentTaskId == parent.Id);
|
||||||
|
flat.AddRange(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateTime.Today;
|
||||||
|
foreach (var r in flat)
|
||||||
{
|
{
|
||||||
if (r.Done)
|
if (r.Done)
|
||||||
CompletedItems.Add(r);
|
CompletedItems.Add(r);
|
||||||
@@ -356,6 +381,79 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty);
|
private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || row.Status != TaskStatus.Manual) return;
|
||||||
|
try { await _worker!.StartPlanningSessionAsync(row.Id); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || !row.IsPlanningParent) return;
|
||||||
|
if (_worker is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var draftCount = await _worker.GetPendingDraftCountAsync(row.Id);
|
||||||
|
var modalVm = new UnfinishedPlanningModalViewModel
|
||||||
|
{
|
||||||
|
TaskTitle = row.Title,
|
||||||
|
DraftCount = draftCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ShowUnfinishedPlanningModal is null)
|
||||||
|
return;
|
||||||
|
await ShowUnfinishedPlanningModal(modalVm);
|
||||||
|
|
||||||
|
var choice = await modalVm.Result.Task;
|
||||||
|
|
||||||
|
switch (choice)
|
||||||
|
{
|
||||||
|
case UnfinishedPlanningModalResult.Resume:
|
||||||
|
await _worker.ResumePlanningSessionAsync(row.Id);
|
||||||
|
break;
|
||||||
|
case UnfinishedPlanningModalResult.FinalizeNow:
|
||||||
|
await _worker.FinalizePlanningSessionAsync(row.Id);
|
||||||
|
break;
|
||||||
|
case UnfinishedPlanningModalResult.Discard:
|
||||||
|
await _worker.DiscardPlanningSessionAsync(row.Id);
|
||||||
|
break;
|
||||||
|
case UnfinishedPlanningModalResult.Cancel:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
try { await _worker!.DiscardPlanningSessionAsync(row.Id); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleExpand(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : row.IsExpanded);
|
||||||
|
_expandedState[row.Id] = next;
|
||||||
|
row.IsExpanded = next;
|
||||||
|
Regroup();
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
|
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
|
||||||
{
|
{
|
||||||
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
|
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
|
||||||
|
|||||||
@@ -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.ConfirmAsync = ShowConfirmAsync;
|
||||||
|
vm.ShowErrorAsync = ShowErrorDialogAsync;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner == null) return;
|
||||||
|
|
||||||
|
var ok = new Button { Content = "OK", MinWidth = 90 };
|
||||||
|
|
||||||
|
var dialog = new Window
|
||||||
|
{
|
||||||
|
Title = "Error",
|
||||||
|
Width = 360,
|
||||||
|
SizeToContent = SizeToContent.Height,
|
||||||
|
CanResize = false,
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||||
|
ShowInTaskbar = false,
|
||||||
|
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||||
|
Content = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 16,
|
||||||
|
Margin = new Thickness(20),
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||||
|
new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Spacing = 8,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
Children = { ok }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ok.Click += (_, _) => dialog.Close();
|
||||||
|
|
||||||
|
await dialog.ShowDialog(owner);
|
||||||
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
||||||
{
|
{
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
|||||||
@@ -15,134 +15,192 @@
|
|||||||
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
||||||
IsVisible="{Binding DropHintAbove}"/>
|
IsVisible="{Binding DropHintAbove}"/>
|
||||||
|
|
||||||
<Border Grid.Row="1" Classes="task-row"
|
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
|
||||||
Margin="0"
|
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||||
Classes.selected="{Binding IsSelected}"
|
|
||||||
Classes.done="{Binding Done}">
|
<!-- Indent track (only visible for child tasks) -->
|
||||||
<Border.ContextMenu>
|
<Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
|
||||||
<ContextMenu>
|
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
|
||||||
<MenuItem Header="Send to queue"
|
HorizontalAlignment="Right" Margin="0,4"/>
|
||||||
IsVisible="{Binding !IsQueued}"
|
</Border>
|
||||||
Click="OnSendToQueueClick"/>
|
|
||||||
<MenuItem Header="Remove from queue"
|
<!-- Main task card -->
|
||||||
|
<Border Grid.Column="1" Classes="task-row"
|
||||||
|
Margin="0"
|
||||||
|
Classes.selected="{Binding IsSelected}"
|
||||||
|
Classes.done="{Binding Done}">
|
||||||
|
<Border.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="Send to queue"
|
||||||
|
IsVisible="{Binding !IsQueued}"
|
||||||
|
Click="OnSendToQueueClick"/>
|
||||||
|
<MenuItem Header="Remove from queue"
|
||||||
|
IsVisible="{Binding IsQueued}"
|
||||||
|
Click="OnRemoveFromQueueClick"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Open planning Session"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).OpenPlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding CanOpenPlanningSession}"/>
|
||||||
|
<MenuItem Header="Resume planning Session"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ResumePlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||||
|
<MenuItem Header="Discard planning session"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).DiscardPlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
|
||||||
|
<MenuItem Header="Clear schedule"
|
||||||
|
IsVisible="{Binding HasSchedule}"
|
||||||
|
Click="OnClearScheduleClick"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</Border.ContextMenu>
|
||||||
|
<Grid ColumnDefinitions="0,18,32,*,Auto,32" Margin="6,8,10,8">
|
||||||
|
|
||||||
|
<!-- Chevron toggle (only for planning parent tasks) -->
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
IsVisible="{Binding IsPlanningParent}"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleExpandCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
Classes="icon-btn"
|
||||||
|
Width="18" Height="18"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Panel>
|
||||||
|
<TextBlock Text="▾" FontSize="10" IsVisible="{Binding IsExpanded}"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="▸" FontSize="10" IsVisible="{Binding !IsExpanded}"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Center"/>
|
||||||
|
</Panel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Done toggle -->
|
||||||
|
<Button Grid.Column="2" Classes="flat" VerticalAlignment="Top"
|
||||||
|
Margin="0,2,0,0"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleDoneCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Ellipse Width="18" Height="18" Classes="task-check"
|
||||||
|
Classes.done="{Binding Done}"/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Title + chip row + live tail -->
|
||||||
|
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="task-title"
|
||||||
|
Text="{Binding Title}" FontSize="14"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
|
||||||
|
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
|
||||||
|
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
|
||||||
|
|
||||||
|
<!-- Badges: DRAFT and planning session -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
|
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
||||||
|
<TextBlock Text="DRAFT"/>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="badge planning" IsVisible="{Binding IsPlanning}">
|
||||||
|
<TextBlock Text="PLANNING"/>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="badge planned" IsVisible="{Binding IsPlanned}">
|
||||||
|
<TextBlock Text="PLANNED"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Chip row -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
|
||||||
|
<!-- Status chip -->
|
||||||
|
<Border Classes="chip"
|
||||||
|
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
||||||
|
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
|
||||||
|
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
|
||||||
|
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
|
||||||
|
<TextBlock Text="{Binding Status}"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Dequeue button (only when Queued) -->
|
||||||
|
<Button Classes="icon-btn dequeue-btn"
|
||||||
IsVisible="{Binding IsQueued}"
|
IsVisible="{Binding IsQueued}"
|
||||||
Click="OnRemoveFromQueueClick"/>
|
ToolTip.Tip="Remove from queue"
|
||||||
<Separator/>
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
|
||||||
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
|
CommandParameter="{Binding}">
|
||||||
<MenuItem Header="Clear schedule"
|
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
|
||||||
IsVisible="{Binding HasSchedule}"
|
</Button>
|
||||||
Click="OnClearScheduleClick"/>
|
|
||||||
</ContextMenu>
|
|
||||||
</Border.ContextMenu>
|
|
||||||
<Grid ColumnDefinitions="0,32,*,32" Margin="6,8,10,8">
|
|
||||||
|
|
||||||
<!-- Done toggle -->
|
<!-- List chip with dot -->
|
||||||
<Button Grid.Column="1" Classes="flat" VerticalAlignment="Top"
|
<Border Classes="chip chip-list">
|
||||||
Margin="0,2,0,0"
|
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleDoneCommand}"
|
<Ellipse Width="6" Height="6"
|
||||||
CommandParameter="{Binding}">
|
Fill="{DynamicResource MossBrush}"
|
||||||
<Ellipse Width="18" Height="18" Classes="task-check"
|
VerticalAlignment="Center"/>
|
||||||
Classes.done="{Binding Done}"/>
|
<TextBlock Text="{Binding ListName}"/>
|
||||||
</Button>
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Title + chip row + live tail -->
|
<!-- Branch chip -->
|
||||||
<StackPanel Grid.Column="2" Spacing="6" VerticalAlignment="Center">
|
<Border Classes="chip chip-branch" IsVisible="{Binding HasBranch}">
|
||||||
<TextBlock Classes="task-title"
|
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
Text="{Binding Title}" FontSize="14"
|
<PathIcon Width="10" Height="10"
|
||||||
Foreground="{DynamicResource TextBrush}"
|
Data="{StaticResource Icon.GitBranch}"
|
||||||
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Text="{Binding Branch}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Chip row -->
|
<!-- Diff chip -->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<Border Classes="chip chip-diff" IsVisible="{Binding HasDiff}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="diff-add" Text="{Binding DiffAdditionsText}"/>
|
||||||
|
<TextBlock Classes="diff-del" Text="{Binding DiffDeletionsText}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Status chip -->
|
<!-- Tag chips -->
|
||||||
<Border Classes="chip"
|
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
|
||||||
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
<ItemsControl.ItemsPanel>
|
||||||
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
|
<ItemsPanelTemplate>
|
||||||
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
|
<StackPanel Orientation="Horizontal" Spacing="6"/>
|
||||||
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
|
</ItemsPanelTemplate>
|
||||||
<TextBlock Text="{Binding Status}"/>
|
</ItemsControl.ItemsPanel>
|
||||||
</Border>
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Classes="chip chip-tag">
|
||||||
|
<TextBlock Text="{Binding}"/>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Dequeue button (only when Queued) -->
|
<!-- Live-tail row (visible when running + has tail) -->
|
||||||
<Button Classes="icon-btn dequeue-btn"
|
<Border Classes="task-live-tail" IsVisible="{Binding HasLiveTail}">
|
||||||
IsVisible="{Binding IsQueued}"
|
<StackPanel Spacing="3">
|
||||||
ToolTip.Tip="Remove from queue"
|
<TextBlock Text="{Binding LiveTail}"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
|
TextTrimming="CharacterEllipsis" MaxLines="1"/>
|
||||||
CommandParameter="{Binding}">
|
<Grid Height="3" HorizontalAlignment="Stretch">
|
||||||
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
|
<Rectangle Fill="{DynamicResource Surface3Brush}"
|
||||||
</Button>
|
HorizontalAlignment="Stretch" RadiusX="1.5" RadiusY="1.5"/>
|
||||||
|
<Rectangle Fill="{DynamicResource MossBrush}"
|
||||||
<!-- List chip with dot -->
|
HorizontalAlignment="Left" Width="60" RadiusX="1.5" RadiusY="1.5"/>
|
||||||
<Border Classes="chip chip-list">
|
</Grid>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
|
||||||
<Ellipse Width="6" Height="6"
|
|
||||||
Fill="{DynamicResource MossBrush}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="{Binding ListName}"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Branch chip -->
|
|
||||||
<Border Classes="chip chip-branch" IsVisible="{Binding HasBranch}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
|
||||||
<PathIcon Width="10" Height="10"
|
|
||||||
Data="{StaticResource Icon.GitBranch}"
|
|
||||||
Foreground="{DynamicResource TextDimBrush}"/>
|
|
||||||
<TextBlock Text="{Binding Branch}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Diff chip -->
|
|
||||||
<Border Classes="chip chip-diff" IsVisible="{Binding HasDiff}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
|
||||||
<TextBlock Classes="diff-add" Text="{Binding DiffAdditionsText}"/>
|
|
||||||
<TextBlock Classes="diff-del" Text="{Binding DiffDeletionsText}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Tag chips -->
|
|
||||||
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
|
|
||||||
<ItemsControl.ItemsPanel>
|
|
||||||
<ItemsPanelTemplate>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6"/>
|
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<Border Classes="chip chip-tag">
|
|
||||||
<TextBlock Text="{Binding}"/>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Live-tail row (visible when running + has tail) -->
|
<!-- Star toggle -->
|
||||||
<Border Classes="task-live-tail" IsVisible="{Binding HasLiveTail}">
|
<Button Grid.Column="5" Classes="icon-btn star-btn"
|
||||||
<StackPanel Spacing="3">
|
Classes.on="{Binding IsStarred}"
|
||||||
<TextBlock Text="{Binding LiveTail}"
|
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||||
TextTrimming="CharacterEllipsis" MaxLines="1"/>
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
|
||||||
<Grid Height="3" HorizontalAlignment="Stretch">
|
CommandParameter="{Binding}">
|
||||||
<Rectangle Fill="{DynamicResource Surface3Brush}"
|
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
|
||||||
HorizontalAlignment="Stretch" RadiusX="1.5" RadiusY="1.5"/>
|
</Button>
|
||||||
<Rectangle Fill="{DynamicResource MossBrush}"
|
</Grid>
|
||||||
HorizontalAlignment="Left" Width="60" RadiusX="1.5" RadiusY="1.5"/>
|
</Border>
|
||||||
</Grid>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Star toggle -->
|
</Grid>
|
||||||
<Button Grid.Column="3" Classes="icon-btn star-btn"
|
|
||||||
Classes.on="{Binding IsStarred}"
|
|
||||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
|
|
||||||
CommandParameter="{Binding}">
|
|
||||||
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Below-row indicator: only expands when visible (used for the last row of a section) -->
|
<!-- Below-row indicator: only expands when visible (used for the last row of a section) -->
|
||||||
<Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}">
|
<Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}">
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using Avalonia.Input;
|
|||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
using ClaudeDo.Ui.Views.Modals;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands;
|
namespace ClaudeDo.Ui.Views.Islands;
|
||||||
|
|
||||||
@@ -19,7 +21,19 @@ public partial class TasksIslandView : UserControl
|
|||||||
DataContextChanged += (_, _) =>
|
DataContextChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
if (DataContext is TasksIslandViewModel vm)
|
if (DataContext is TasksIslandViewModel vm)
|
||||||
|
{
|
||||||
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
|
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
|
||||||
|
vm.ShowUnfinishedPlanningModal = async (modalVm) =>
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner is null) { modalVm.CancelCommand.Execute(null); return; }
|
||||||
|
var modal = new UnfinishedPlanningModalView { DataContext = modalVm };
|
||||||
|
// Closing via the OS title-bar (if ever enabled) also resolves the TCS.
|
||||||
|
modal.Closed += (_, _) => modalVm.CancelCommand.Execute(null);
|
||||||
|
await modal.ShowDialog(owner);
|
||||||
|
// ShowDialog completes once the window is closed (CloseAction or OS close).
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -331,6 +331,8 @@ public sealed class TaskRunner
|
|||||||
{
|
{
|
||||||
var taskRepo = new TaskRepository(context);
|
var taskRepo = new TaskRepository(context);
|
||||||
await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
||||||
|
if (task.ParentTaskId is not null)
|
||||||
|
await taskRepo.TryCompleteParentAsync(task.ParentTaskId, CancellationToken.None);
|
||||||
}
|
}
|
||||||
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
|
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||||
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
||||||
@@ -346,6 +348,9 @@ public sealed class TaskRunner
|
|||||||
using var context = _dbFactory.CreateDbContext();
|
using var context = _dbFactory.CreateDbContext();
|
||||||
var taskRepo = new TaskRepository(context);
|
var taskRepo = new TaskRepository(context);
|
||||||
await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
|
await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
|
||||||
|
var justFailed = await taskRepo.GetByIdAsync(taskId, CancellationToken.None);
|
||||||
|
if (justFailed?.ParentTaskId is not null)
|
||||||
|
await taskRepo.TryCompleteParentAsync(justFailed.ParentTaskId, CancellationToken.None);
|
||||||
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
|
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
|
||||||
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
|
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
|
||||||
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
|
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
|
||||||
@@ -360,6 +365,9 @@ public sealed class TaskRunner
|
|||||||
using var context = _dbFactory.CreateDbContext();
|
using var context = _dbFactory.CreateDbContext();
|
||||||
var taskRepo = new TaskRepository(context);
|
var taskRepo = new TaskRepository(context);
|
||||||
await taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
|
await taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
|
||||||
|
var justFailed = await taskRepo.GetByIdAsync(taskId, CancellationToken.None);
|
||||||
|
if (justFailed?.ParentTaskId is not null)
|
||||||
|
await taskRepo.TryCompleteParentAsync(justFailed.ParentTaskId, CancellationToken.None);
|
||||||
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
|
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
|
||||||
await _broadcaster.TaskFinished(slot, taskId, "failed", now);
|
await _broadcaster.TaskFinished(slot, taskId, "failed", now);
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Repositories;
|
||||||
|
|
||||||
|
public sealed class TaskRepositoryParentCompletionTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
|
||||||
|
public TaskRepositoryParentCompletionTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||||
|
|
||||||
|
private async Task<string> ListAsync()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaskEntity> PlannedParentAsync(string listId)
|
||||||
|
{
|
||||||
|
var parent = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "p",
|
||||||
|
Status = TaskStatus.Planned,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaskEntity> ChildAsync(string listId, string parentId, TaskStatus status)
|
||||||
|
{
|
||||||
|
var child = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "c",
|
||||||
|
Status = status,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
ParentTaskId = parentId,
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteParentAsync_AllChildrenDone_ParentBecomesDone()
|
||||||
|
{
|
||||||
|
var listId = await ListAsync();
|
||||||
|
var parent = await PlannedParentAsync(listId);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
||||||
|
|
||||||
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, loaded!.Status);
|
||||||
|
Assert.NotNull(loaded.FinishedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteParentAsync_OneFailedRestDone_ParentBecomesFailed()
|
||||||
|
{
|
||||||
|
var listId = await ListAsync();
|
||||||
|
var parent = await PlannedParentAsync(listId);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Failed);
|
||||||
|
|
||||||
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Failed, loaded!.Status);
|
||||||
|
Assert.NotNull(loaded.FinishedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteParentAsync_OneStillRunning_ParentStaysPlanned()
|
||||||
|
{
|
||||||
|
var listId = await ListAsync();
|
||||||
|
var parent = await PlannedParentAsync(listId);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Running);
|
||||||
|
|
||||||
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
||||||
|
Assert.Null(loaded.FinishedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteParentAsync_ChildStillDraft_ParentStaysPlanned()
|
||||||
|
{
|
||||||
|
var listId = await ListAsync();
|
||||||
|
var parent = await PlannedParentAsync(listId);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Draft);
|
||||||
|
|
||||||
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteParentAsync_ParentIsNotPlanned_NoChange()
|
||||||
|
{
|
||||||
|
var listId = await ListAsync();
|
||||||
|
var parent = await PlannedParentAsync(listId);
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync("UPDATE tasks SET status = 'planning' WHERE id = {0}", parent.Id);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
||||||
|
|
||||||
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Repositories;
|
||||||
|
|
||||||
|
public sealed class TaskRepositoryPlanningTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
private readonly TagRepository _tags;
|
||||||
|
|
||||||
|
public TaskRepositoryPlanningTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
_tags = new TagRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateListAsync(string? id = null)
|
||||||
|
{
|
||||||
|
var listId = id ?? Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId,
|
||||||
|
Name = "Test List",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
return listId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Manual, string? parentId = null) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "t",
|
||||||
|
Status = status,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "feat",
|
||||||
|
ParentTaskId = parentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetChildrenAsync_ReturnsOnlyDirectChildren_Sorted()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Planning);
|
||||||
|
parent.Title = "parent";
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
|
||||||
|
var childA = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id);
|
||||||
|
childA.Title = "a";
|
||||||
|
await _tasks.AddAsync(childA);
|
||||||
|
childA.SortOrder = 1;
|
||||||
|
await _tasks.UpdateAsync(childA);
|
||||||
|
|
||||||
|
var childB = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id);
|
||||||
|
childB.Title = "b";
|
||||||
|
await _tasks.AddAsync(childB);
|
||||||
|
childB.SortOrder = 0;
|
||||||
|
await _tasks.UpdateAsync(childB);
|
||||||
|
|
||||||
|
var unrelated = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(unrelated);
|
||||||
|
|
||||||
|
var children = await _tasks.GetChildrenAsync(parent.Id);
|
||||||
|
|
||||||
|
Assert.Equal(2, children.Count);
|
||||||
|
Assert.Equal("b", children[0].Title);
|
||||||
|
Assert.Equal("a", children[1].Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildAsync_CreatesDraftUnderParent()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Planning);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
|
||||||
|
var child = await _tasks.CreateChildAsync(
|
||||||
|
parent.Id,
|
||||||
|
title: "child title",
|
||||||
|
description: "child desc",
|
||||||
|
tagNames: new[] { "agent" },
|
||||||
|
commitType: "feat");
|
||||||
|
|
||||||
|
Assert.Equal(TaskStatus.Draft, child.Status);
|
||||||
|
Assert.Equal(parent.Id, child.ParentTaskId);
|
||||||
|
Assert.Equal(listId, child.ListId);
|
||||||
|
Assert.Equal("child title", child.Title);
|
||||||
|
Assert.Equal("child desc", child.Description);
|
||||||
|
Assert.Equal("feat", child.CommitType);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(child.Id);
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal(TaskStatus.Draft, loaded!.Status);
|
||||||
|
|
||||||
|
var tags = await _tasks.GetTagsAsync(child.Id);
|
||||||
|
Assert.Contains(tags, t => t.Name == "agent");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildAsync_ThrowsIfParentNotFound()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
_ = listId; // just to create the DB
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
_tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetPlanningStartedAsync_ManualTask_TransitionsToPlanning()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var task = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
|
||||||
|
var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-abc");
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(TaskStatus.Planning, result!.Status);
|
||||||
|
Assert.Equal("tok-abc", result.PlanningSessionToken);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
||||||
|
Assert.Equal("tok-abc", loaded.PlanningSessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetPlanningStartedAsync_NonManualTask_ReturnsNull()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var task = MakeTask(listId, TaskStatus.Queued);
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
|
||||||
|
var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-xyz");
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Queued, loaded!.Status);
|
||||||
|
Assert.Null(loaded.PlanningSessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdatePlanningSessionIdAsync_StoresClaudeSessionId()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var task = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
await _tasks.SetPlanningStartedAsync(task.Id, "tok");
|
||||||
|
|
||||||
|
await _tasks.UpdatePlanningSessionIdAsync(task.Id, "claude-session-42");
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal("claude-session-42", loaded!.PlanningSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FindByPlanningTokenAsync_ReturnsTask_WhenTokenMatches()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var task = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
await _tasks.SetPlanningStartedAsync(task.Id, "unique-token-123");
|
||||||
|
|
||||||
|
var found = await _tasks.FindByPlanningTokenAsync("unique-token-123");
|
||||||
|
|
||||||
|
Assert.NotNull(found);
|
||||||
|
Assert.Equal(task.Id, found!.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FindByPlanningTokenAsync_ReturnsNull_WhenTokenUnknown()
|
||||||
|
{
|
||||||
|
var found = await _tasks.FindByPlanningTokenAsync("no-such-token");
|
||||||
|
Assert.Null(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FinalizePlanningAsync_TransitionsDraftsAndParent()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||||
|
|
||||||
|
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, tagNames: new[] { "agent" }, commitType: null);
|
||||||
|
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, tagNames: null, commitType: null);
|
||||||
|
|
||||||
|
var count = await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true);
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
|
||||||
|
var c1Loaded = await _tasks.GetByIdAsync(c1.Id);
|
||||||
|
var c2Loaded = await _tasks.GetByIdAsync(c2.Id);
|
||||||
|
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
|
||||||
|
Assert.Equal(TaskStatus.Queued, c1Loaded!.Status);
|
||||||
|
Assert.Equal(TaskStatus.Manual, c2Loaded!.Status);
|
||||||
|
Assert.Equal(TaskStatus.Planned, parentLoaded!.Status);
|
||||||
|
Assert.NotNull(parentLoaded.PlanningFinalizedAt);
|
||||||
|
Assert.Null(parentLoaded.PlanningSessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FinalizePlanningAsync_QueueAgentTasksFalse_AllToManual()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||||
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: new[] { "agent" }, commitType: null);
|
||||||
|
|
||||||
|
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false);
|
||||||
|
|
||||||
|
var cLoaded = await _tasks.GetByIdAsync(c.Id);
|
||||||
|
Assert.Equal(TaskStatus.Manual, cLoaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FinalizePlanningAsync_ParentWithAgentListTag_ChildIsQueued()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
||||||
|
await _lists.AddTagAsync(listId, agentTagId);
|
||||||
|
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||||
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: null, commitType: null);
|
||||||
|
|
||||||
|
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true);
|
||||||
|
|
||||||
|
var cLoaded = await _tasks.GetByIdAsync(c.Id);
|
||||||
|
Assert.Equal(TaskStatus.Queued, cLoaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanningAsync_DeletesDraftsAndResetsParent()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||||
|
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42");
|
||||||
|
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||||
|
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||||
|
|
||||||
|
var ok = await _tasks.DiscardPlanningAsync(parent.Id);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Null(await _tasks.GetByIdAsync(c1.Id));
|
||||||
|
Assert.Null(await _tasks.GetByIdAsync(c2.Id));
|
||||||
|
|
||||||
|
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Manual, parentLoaded!.Status);
|
||||||
|
Assert.Null(parentLoaded.PlanningSessionId);
|
||||||
|
Assert.Null(parentLoaded.PlanningSessionToken);
|
||||||
|
Assert.Null(parentLoaded.PlanningFinalizedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanningAsync_OnNonPlanningTask_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var task = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
|
||||||
|
var ok = await _tasks.DiscardPlanningAsync(task.Id);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_ParentWithChildren_ThrowsOrDoesNotDelete()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Planning);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
|
||||||
|
// ExecuteDelete bypasses EF change tracking, so SQLite's FK enforcement
|
||||||
|
// (foreign_keys = ON, set by ClaudeDoDbContext) throws SqliteException directly.
|
||||||
|
await Assert.ThrowsAsync<Microsoft.Data.Sqlite.SqliteException>(async () =>
|
||||||
|
{
|
||||||
|
await _tasks.DeleteAsync(parent.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
var stillThere = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.NotNull(stillThere);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetNextQueuedAgentTask_SkipsDraftPlanningPlanned()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
||||||
|
|
||||||
|
async Task<TaskEntity> T(TaskStatus s, bool withTag, string? parent = null)
|
||||||
|
{
|
||||||
|
var t = MakeTask(listId, s, parentId: parent);
|
||||||
|
await _tasks.AddAsync(t);
|
||||||
|
if (withTag) await _tasks.AddTagAsync(t.Id, agentTagId);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
var planning = await T(TaskStatus.Planning, withTag: true);
|
||||||
|
var planned = await T(TaskStatus.Planned, withTag: true);
|
||||||
|
var draft = await T(TaskStatus.Draft, withTag: true, parent: planning.Id);
|
||||||
|
var queued = await T(TaskStatus.Queued, withTag: true);
|
||||||
|
|
||||||
|
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
|
||||||
|
|
||||||
|
Assert.NotNull(picked);
|
||||||
|
Assert.Equal(queued.Id, picked!.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Runner;
|
||||||
|
|
||||||
|
public sealed class TaskRunnerParentCompletionTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
|
||||||
|
public TaskRunnerParentCompletionTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChildMarkedDone_LastOne_ParentFinalized()
|
||||||
|
{
|
||||||
|
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.Planned,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
|
||||||
|
var c1 = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "c1",
|
||||||
|
Status = TaskStatus.Done,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
ParentTaskId = parent.Id,
|
||||||
|
};
|
||||||
|
var c2 = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "c2",
|
||||||
|
Status = TaskStatus.Running,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
ParentTaskId = parent.Id,
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(c1);
|
||||||
|
await _tasks.AddAsync(c2);
|
||||||
|
|
||||||
|
// Simulate the runner finishing the second child:
|
||||||
|
await _tasks.MarkDoneAsync(c2.Id, DateTime.UtcNow, "done");
|
||||||
|
if (c2.ParentTaskId is not null)
|
||||||
|
await _tasks.TryCompleteParentAsync(c2.ParentTaskId);
|
||||||
|
|
||||||
|
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, parentLoaded!.Status);
|
||||||
|
Assert.NotNull(parentLoaded.FinishedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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