Compare commits
27 Commits
a6608bf8b3
...
0b19ea739c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b19ea739c | ||
|
|
3587703fe8 | ||
|
|
7e3ae704fe | ||
|
|
232d7cb647 | ||
|
|
6c8048d0be | ||
|
|
6670771040 | ||
|
|
bc15c16e44 | ||
|
|
ca71275fc4 | ||
|
|
8f4e37ef56 | ||
|
|
789094fcd9 | ||
|
|
9f70f6747e | ||
|
|
182a9df7f3 | ||
|
|
79131f83c1 | ||
|
|
b888a5f0cd | ||
|
|
046da0fd81 | ||
|
|
b095a29f97 | ||
|
|
ce30d01b72 | ||
|
|
89f6b836ba | ||
|
|
b944597af4 | ||
|
|
5da69ee6aa | ||
|
|
5308ba3136 | ||
|
|
a62ef240d1 | ||
|
|
623ebf147b | ||
|
|
8d34db3f9b | ||
|
|
0d55002e5e | ||
|
|
d094a21e09 | ||
|
|
e68bb737e3 |
@@ -8,6 +8,7 @@
|
|||||||
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj" />
|
||||||
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
||||||
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
||||||
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
|
||||||
|
|||||||
1423
docs/superpowers/plans/2026-05-19-worktree-overview-modal.md
Normal file
1423
docs/superpowers/plans/2026-05-19-worktree-overview-modal.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
|||||||
|
# Worktree Overview Modal — Design
|
||||||
|
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-05-19
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Worktree management is becoming hard to oversee. The current UI only exposes per-task worktree actions (merge / keep / discard) from `TaskDetailView`, plus two global maintenance buttons (`CleanupFinishedWorktrees`, `ResetAllWorktrees`). There is no view that shows *all existing worktrees at a glance* with their state, age, branch, and diff stat. Stale or "phantom" worktrees (DB row but missing directory, or vice versa) have no targeted recovery path.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- A modal that lists every worktree row from the DB, joined with task + list metadata.
|
||||||
|
- Two entry points: filtered to one list (List context menu), and global grouped by list (Help menu).
|
||||||
|
- Quick per-row actions hidden behind a right-click context menu.
|
||||||
|
- Targeted force-remove for stuck / phantom worktrees.
|
||||||
|
- Manual refresh only; no live SignalR subscription needed.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No auto-refresh / live updates from SignalR events.
|
||||||
|
- No UI tests (the project has none for the Ui project).
|
||||||
|
- No changes to `WorktreeManager`, `TaskRunner`, or the existing per-worktree file-tree modal (`WorktreeModalView`) — it gets reused as the "Show diff" target.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
### New view pair
|
||||||
|
|
||||||
|
`WorktreesOverviewModalView` + `WorktreesOverviewModalViewModel`, parallel to existing `WorktreeModalView` (which shows the *file tree inside one* worktree).
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Worktrees [List "Foo"] or Worktrees (all) ───────────────┐
|
||||||
|
│ [ Refresh ] [ Cleanup finished ] │
|
||||||
|
│ │
|
||||||
|
│ ▼ List Foo (global mode only) │
|
||||||
|
│ Title Branch State +/- Age │
|
||||||
|
│ Fix login bug claudedo/ab… Active +42-7 3h ago │
|
||||||
|
│ Add API … claudedo/cd… Merged +8 -0 1d ago │
|
||||||
|
│ ▼ List Bar │
|
||||||
|
│ … │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- `DataGrid` (or `ItemsControl` with Grid template) for rows.
|
||||||
|
- List-filtered mode: no group headers, just the table.
|
||||||
|
- Global mode: `Expander` per list with list name as header (default expanded).
|
||||||
|
- State as a colored badge — new `WorktreeStateColorConverter` analogous to `StatusColorConverter`:
|
||||||
|
- Active=Blue, Merged=Green, Discarded=Gray, Kept=Orange.
|
||||||
|
- Right-click on a row opens a `MenuFlyout` with all actions.
|
||||||
|
- Phantom rows (`PathExistsOnDisk == false`) get a small warning icon in the Path tooltip area.
|
||||||
|
|
||||||
|
### Default sort
|
||||||
|
|
||||||
|
State (Active first), then `CreatedAt` descending. Same inside each list group in global mode.
|
||||||
|
|
||||||
|
### Per-row context menu
|
||||||
|
|
||||||
|
| Item | Enabled when | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| Show diff | always | Opens existing `WorktreeModalView` with `WorktreePath` set |
|
||||||
|
| Open in Explorer | `PathExistsOnDisk == true` | `Process.Start("explorer.exe", path)` |
|
||||||
|
| Jump to task | always | Closes modal, selects list + task in main window |
|
||||||
|
| Merge | `State == Active` | Calls existing `MergeTask` hub method |
|
||||||
|
| Discard | `State == Active` | `SetWorktreeState(taskId, Discarded)` |
|
||||||
|
| Keep | `State == Active` | `SetWorktreeState(taskId, Kept)` |
|
||||||
|
| Copy branch | always | Clipboard |
|
||||||
|
| Copy path | always | Clipboard |
|
||||||
|
| —————— | | (separator) |
|
||||||
|
| Force remove | `Task.Status != Running` | Confirmation dialog → `ForceRemoveWorktree(taskId)` (red label) |
|
||||||
|
|
||||||
|
### Bulk buttons (toolbar)
|
||||||
|
|
||||||
|
- **Refresh** — re-runs `GetWorktreesOverview`.
|
||||||
|
- **Cleanup finished** — `CleanupFinishedWorktrees(listId)`; in list-filtered mode acts on that list, in global mode on all.
|
||||||
|
|
||||||
|
### Entry points
|
||||||
|
|
||||||
|
- **List context menu** → "Worktrees anzeigen…" → opens modal in filtered mode (`listId` = the list).
|
||||||
|
- **Help menu** → "Worktrees" → opens modal in global mode (`listId = null`).
|
||||||
|
|
||||||
|
`MainWindowViewModel` gets `OpenWorktreesOverviewCommand(listId)` and `OpenWorktreesOverviewGlobalCommand()`, both using a DI `Func<WorktreesOverviewModalViewModel>` factory analogous to existing editor patterns.
|
||||||
|
|
||||||
|
## SignalR Contract
|
||||||
|
|
||||||
|
### New `WorkerHub` methods
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task<IReadOnlyList<WorktreeOverviewDto>> GetWorktreesOverview(string? listId);
|
||||||
|
Task<bool> SetWorktreeState(string taskId, WorktreeState newState);
|
||||||
|
Task<ForceRemoveResultDto> ForceRemoveWorktree(string taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
`CleanupFinishedWorktrees` already exists — extend its signature to accept an optional `listId`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task<CleanupResult> CleanupFinishedWorktrees(string? listId); // was: ()
|
||||||
|
```
|
||||||
|
|
||||||
|
`MergeTask` is reused unchanged.
|
||||||
|
|
||||||
|
### DTOs
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record WorktreeOverviewDto(
|
||||||
|
string TaskId,
|
||||||
|
string TaskTitle,
|
||||||
|
TaskStatus TaskStatus,
|
||||||
|
string ListId,
|
||||||
|
string ListName,
|
||||||
|
string Path,
|
||||||
|
string BranchName,
|
||||||
|
WorktreeState State,
|
||||||
|
string? DiffStat,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
bool PathExistsOnDisk);
|
||||||
|
|
||||||
|
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Broadcasts
|
||||||
|
|
||||||
|
After successful `SetWorktreeState` and `ForceRemoveWorktree`, fire `HubBroadcaster.WorktreeUpdated(taskId)` so `TaskDetailView` (if open) refreshes. `CleanupFinishedWorktrees` already broadcasts; keep behavior, optionally batch.
|
||||||
|
|
||||||
|
### `WorkerClient` (UI)
|
||||||
|
|
||||||
|
Add wrapper methods for the four new/changed hub calls.
|
||||||
|
|
||||||
|
## Backend Changes
|
||||||
|
|
||||||
|
### `WorktreeMaintenanceService`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record ForceRemoveResult(bool Removed, string? Reason);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(string? listId, CancellationToken ct);
|
||||||
|
public Task<CleanupResult> CleanupFinishedAsync(string? listId, CancellationToken ct); // signature extended
|
||||||
|
public Task<ForceRemoveResult> ForceRemoveAsync(string taskId, CancellationToken ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `GetOverviewAsync` — joins `worktrees × tasks × lists` (`AsNoTracking`), maps to DTO including `PathExistsOnDisk = Directory.Exists(path)`.
|
||||||
|
- `CleanupFinishedAsync(listId)` — same join as today but also filters `t.ListId == listId` when not null.
|
||||||
|
- `ForceRemoveAsync` — refactors existing `TryRemoveAsync(row, force: true, …)` into a single-row entry point shared with `ResetAllAsync`. Refuses when the task is currently `Running`, returning `ForceRemoveResult(false, "task is currently running")`. Otherwise removes the worktree directory, prunes, deletes the branch, deletes the DB row.
|
||||||
|
|
||||||
|
### `WorktreeRepository`
|
||||||
|
|
||||||
|
`SetStateAsync(string taskId, WorktreeState newState, CancellationToken ct)` already documented in CLAUDE.md. If absent, add it; if present, just expose it via the hub.
|
||||||
|
|
||||||
|
### Unchanged
|
||||||
|
|
||||||
|
`WorktreeManager`, `TaskRunner`, `WorktreeModalView`, all existing merge / cleanup flows.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. User opens modal → `WorkerClient.GetWorktreesOverviewAsync(listId)` → bind rows.
|
||||||
|
2. Refresh button → same call.
|
||||||
|
3. Per-row action → corresponding hub call → on success, update the affected row locally (no full reload).
|
||||||
|
4. Bulk Cleanup → hub call → full reload.
|
||||||
|
|
||||||
|
## Force-Remove Semantics
|
||||||
|
|
||||||
|
| Initial state | Result |
|
||||||
|
|---|---|
|
||||||
|
| Active, task not Running | Worktree dir removed, branch deleted, DB row deleted. Task remains in current status (Done/Failed/Idle). |
|
||||||
|
| Active, task Running | Refused with reason "task is currently running". |
|
||||||
|
| Merged / Discarded / Kept | Same removal path. |
|
||||||
|
| Phantom (dir missing) | DB row deleted, branch best-effort deleted. |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
New tests in `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs` (real SQLite, real git):
|
||||||
|
|
||||||
|
1. `GetOverviewAsync_returns_all_when_listId_null`
|
||||||
|
2. `GetOverviewAsync_filters_by_listId`
|
||||||
|
3. `GetOverviewAsync_flags_PathExistsOnDisk_false_for_phantom_row`
|
||||||
|
4. `CleanupFinishedAsync_filters_by_listId`
|
||||||
|
5. `ForceRemoveAsync_removes_active_worktree` (happy path incl. branch delete)
|
||||||
|
6. `ForceRemoveAsync_blocked_when_task_running`
|
||||||
|
7. `ForceRemoveAsync_removes_phantom_row`
|
||||||
|
|
||||||
|
UI verification (manual):
|
||||||
|
|
||||||
|
- Open from list context menu → only that list's rows.
|
||||||
|
- Open from Help menu → all lists grouped, default expanded.
|
||||||
|
- Force-remove an Active worktree → row vanishes, DB row gone, branch deleted.
|
||||||
|
- Force-remove while task Running → toast / dialog with reason, row unchanged.
|
||||||
|
- Cleanup finished in filtered mode → only finished rows of the selected list disappear.
|
||||||
|
- "Show diff" reuses existing `WorktreeModalView`.
|
||||||
|
|
||||||
|
## Files Touched
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs`
|
||||||
|
- `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewDto.cs` (or extend an existing DTOs file)
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs`
|
||||||
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` (Help menu entry, list context menu entry)
|
||||||
|
- `src/ClaudeDo.App/Program.cs` (DI registration of new VM)
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs`
|
||||||
@@ -95,6 +95,9 @@ sealed class Program
|
|||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
sc.AddTransient<WorktreeModalViewModel>();
|
sc.AddTransient<WorktreeModalViewModel>();
|
||||||
|
sc.AddTransient<Func<WorktreeModalViewModel>>(sp => () => sp.GetRequiredService<WorktreeModalViewModel>());
|
||||||
|
sc.AddTransient<WorktreesOverviewModalViewModel>();
|
||||||
|
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
||||||
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
||||||
sc.AddTransient<PrimeClaudeTabViewModel>();
|
sc.AddTransient<PrimeClaudeTabViewModel>();
|
||||||
sc.AddTransient<SettingsModalViewModel>();
|
sc.AddTransient<SettingsModalViewModel>();
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ public class ClaudeDoDbContext : DbContext
|
|||||||
|
|
||||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||||
public DbSet<TagEntity> Tags => Set<TagEntity>();
|
|
||||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||||
|
|||||||
@@ -21,16 +21,5 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
|
|||||||
.WithOne(c => c.List)
|
.WithOne(c => c.List)
|
||||||
.HasForeignKey<ListConfigEntity>(c => c.ListId)
|
.HasForeignKey<ListConfigEntity>(c => c.ListId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
builder.HasMany(l => l.Tags)
|
|
||||||
.WithMany(tag => tag.Lists)
|
|
||||||
.UsingEntity("list_tags",
|
|
||||||
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
|
|
||||||
r => r.HasOne(typeof(ListEntity)).WithMany().HasForeignKey("list_id").OnDelete(DeleteBehavior.Cascade),
|
|
||||||
j =>
|
|
||||||
{
|
|
||||||
j.HasKey("list_id", "tag_id");
|
|
||||||
j.ToTable("list_tags");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
using ClaudeDo.Data.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Configuration;
|
|
||||||
|
|
||||||
public class TagEntityConfiguration : IEntityTypeConfiguration<TagEntity>
|
|
||||||
{
|
|
||||||
public void Configure(EntityTypeBuilder<TagEntity> builder)
|
|
||||||
{
|
|
||||||
builder.ToTable("tags");
|
|
||||||
|
|
||||||
builder.HasKey(t => t.Id);
|
|
||||||
builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedOnAdd();
|
|
||||||
builder.Property(t => t.Name).HasColumnName("name").IsRequired();
|
|
||||||
builder.HasIndex(t => t.Name).IsUnique();
|
|
||||||
|
|
||||||
builder.HasData(
|
|
||||||
new TagEntity { Id = 1, Name = "agent" },
|
|
||||||
new TagEntity { Id = 2, Name = "manual" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -112,17 +112,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
.WithOne(w => w.Task)
|
.WithOne(w => w.Task)
|
||||||
.HasForeignKey<WorktreeEntity>(w => w.TaskId);
|
.HasForeignKey<WorktreeEntity>(w => w.TaskId);
|
||||||
|
|
||||||
builder.HasMany(t => t.Tags)
|
|
||||||
.WithMany(tag => tag.Tasks)
|
|
||||||
.UsingEntity("task_tags",
|
|
||||||
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
|
|
||||||
r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade),
|
|
||||||
j =>
|
|
||||||
{
|
|
||||||
j.HasKey("task_id", "tag_id");
|
|
||||||
j.ToTable("task_tags");
|
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
||||||
|
|||||||
12
src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs
Normal file
12
src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
|
public sealed class ImportantFilter : ITaskListFilter
|
||||||
|
{
|
||||||
|
public string Id => "smart:important";
|
||||||
|
public bool Matches(TaskEntity t) => t.IsStarred;
|
||||||
|
public bool ShouldCount(TaskEntity t) => t.IsStarred && t.Status != TaskStatus.Done;
|
||||||
|
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||||
|
}
|
||||||
12
src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs
Normal file
12
src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
|
public sealed class MyDayFilter : ITaskListFilter
|
||||||
|
{
|
||||||
|
public string Id => "smart:my-day";
|
||||||
|
public bool Matches(TaskEntity t) => t.IsMyDay;
|
||||||
|
public bool ShouldCount(TaskEntity t) => t.IsMyDay && t.Status != TaskStatus.Done;
|
||||||
|
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||||
|
}
|
||||||
12
src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs
Normal file
12
src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
|
public sealed class PlannedFilter : ITaskListFilter
|
||||||
|
{
|
||||||
|
public string Id => "smart:planned";
|
||||||
|
public bool Matches(TaskEntity t) => t.ScheduledFor != null;
|
||||||
|
public bool ShouldCount(TaskEntity t) => t.ScheduledFor != null && t.Status != TaskStatus.Done;
|
||||||
|
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||||
|
}
|
||||||
14
src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs
Normal file
14
src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
|
public sealed class QueuedFilter : ITaskListFilter
|
||||||
|
{
|
||||||
|
public string Id => "virtual:queued";
|
||||||
|
public bool Matches(TaskEntity t) => t.Status == TaskStatus.Queued;
|
||||||
|
public bool ShouldCount(TaskEntity t) => t.Status == TaskStatus.Queued;
|
||||||
|
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
|
||||||
|
PlanningRules.IsPlanningParent(t) &&
|
||||||
|
PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Queued);
|
||||||
|
}
|
||||||
14
src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs
Normal file
14
src/ClaudeDo.Data/Filtering/Filters/ReviewFilter.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
|
public sealed class ReviewFilter : ITaskListFilter
|
||||||
|
{
|
||||||
|
public string Id => "virtual:review";
|
||||||
|
public bool Matches(TaskEntity t) =>
|
||||||
|
t.Status == TaskStatus.Done &&
|
||||||
|
t.Worktree is { State: WorktreeState.Active };
|
||||||
|
public bool ShouldCount(TaskEntity t) => Matches(t);
|
||||||
|
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||||
|
}
|
||||||
14
src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs
Normal file
14
src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
|
public sealed class RunningFilter : ITaskListFilter
|
||||||
|
{
|
||||||
|
public string Id => "virtual:running";
|
||||||
|
public bool Matches(TaskEntity t) => t.Status == TaskStatus.Running;
|
||||||
|
public bool ShouldCount(TaskEntity t) => t.Status == TaskStatus.Running;
|
||||||
|
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
|
||||||
|
PlanningRules.IsPlanningParent(t) &&
|
||||||
|
PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Running);
|
||||||
|
}
|
||||||
24
src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs
Normal file
24
src/ClaudeDo.Data/Filtering/Filters/UserListFilter.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filter for any user-defined list. Constructed on demand from the list id —
|
||||||
|
/// one instance per list.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserListFilter : ITaskListFilter
|
||||||
|
{
|
||||||
|
private readonly string _listId;
|
||||||
|
|
||||||
|
public UserListFilter(string listId)
|
||||||
|
{
|
||||||
|
_listId = listId;
|
||||||
|
Id = $"user:{listId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id { get; }
|
||||||
|
public bool Matches(TaskEntity t) => t.ListId == _listId;
|
||||||
|
public bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
|
||||||
|
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||||
|
}
|
||||||
26
src/ClaudeDo.Data/Filtering/ITaskListFilter.cs
Normal file
26
src/ClaudeDo.Data/Filtering/ITaskListFilter.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strategy that defines which tasks belong to a single list. One implementation
|
||||||
|
/// per list kind; consumers (counters, list loader) ask the registry for the
|
||||||
|
/// right strategy and never branch on the list id themselves.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITaskListFilter
|
||||||
|
{
|
||||||
|
/// <summary>The list id this filter applies to (e.g. "virtual:queued", "user:abc").</summary>
|
||||||
|
string Id { get; }
|
||||||
|
|
||||||
|
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
|
||||||
|
bool Matches(TaskEntity t);
|
||||||
|
|
||||||
|
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
|
||||||
|
bool ShouldCount(TaskEntity t);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if <paramref name="t"/> is shown as a contextual row (not a primary citizen,
|
||||||
|
/// but appears to host children that match). Default: nothing extra.
|
||||||
|
/// </summary>
|
||||||
|
bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||||
|
}
|
||||||
27
src/ClaudeDo.Data/Filtering/PlanningRules.cs
Normal file
27
src/ClaudeDo.Data/Filtering/PlanningRules.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared predicates that capture planning-hierarchy semantics. Any new rule
|
||||||
|
/// involving parents, children, or planning phases belongs here.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlanningRules
|
||||||
|
{
|
||||||
|
public static bool IsPlanningParent(TaskEntity t) =>
|
||||||
|
t.PlanningPhase != PlanningPhase.None;
|
||||||
|
|
||||||
|
public static bool HasMatchingChild(
|
||||||
|
TaskEntity parent,
|
||||||
|
IReadOnlyList<TaskEntity> all,
|
||||||
|
Func<TaskEntity, bool> childPredicate)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < all.Count; i++)
|
||||||
|
{
|
||||||
|
var c = all[i];
|
||||||
|
if (c.ParentTaskId == parent.Id && childPredicate(c))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs
Normal file
38
src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a list id (e.g. "virtual:queued", "user:abc") to the filter that
|
||||||
|
/// owns its semantics. Smart and virtual filters are singletons; user-list
|
||||||
|
/// filters are constructed on demand from the id.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TaskListFilterRegistry
|
||||||
|
{
|
||||||
|
public const string UserListPrefix = "user:";
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, ITaskListFilter> BuiltIn =
|
||||||
|
new Dictionary<string, ITaskListFilter>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["smart:my-day"] = new MyDayFilter(),
|
||||||
|
["smart:important"] = new ImportantFilter(),
|
||||||
|
["smart:planned"] = new PlannedFilter(),
|
||||||
|
["virtual:queued"] = new QueuedFilter(),
|
||||||
|
["virtual:running"] = new RunningFilter(),
|
||||||
|
["virtual:review"] = new ReviewFilter(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve a filter for a list id, or null if the id is unknown.
|
||||||
|
/// </summary>
|
||||||
|
public ITaskListFilter? Resolve(string listId)
|
||||||
|
{
|
||||||
|
if (BuiltIn.TryGetValue(listId, out var f)) return f;
|
||||||
|
if (listId.StartsWith(UserListPrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var inner = listId[UserListPrefix.Length..];
|
||||||
|
return string.IsNullOrEmpty(inner) ? null : new UserListFilter(inner);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,15 @@ public sealed class GitService
|
|||||||
return stdout;
|
return stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetCommittedFilesAsync(string worktreePath, string baseCommit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
||||||
|
["diff", "--name-status", $"{baseCommit}..HEAD"], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git diff --name-status failed (exit {exitCode}): {stderr}");
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
|
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
|
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
|
||||||
@@ -97,6 +106,15 @@ public sealed class GitService
|
|||||||
return stdout.Trim();
|
return stdout.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetFileDiffAsync(string worktreePath, string? baseCommit, string relativePath, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
string[] args = string.IsNullOrEmpty(baseCommit)
|
||||||
|
? ["diff", "--", relativePath]
|
||||||
|
: ["diff", $"{baseCommit}..HEAD", "--", relativePath];
|
||||||
|
var (_, stdout, _) = await RunGitAsync(worktreePath, args, ct);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task WorktreeRemoveAsync(string repoDir, string worktreePath, bool force = false, CancellationToken ct = default)
|
public async Task WorktreeRemoveAsync(string repoDir, string worktreePath, bool force = false, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var args = new List<string> { "worktree", "remove" };
|
var args = new List<string> { "worktree", "remove" };
|
||||||
|
|||||||
115
src/ClaudeDo.Data/Migrations/20260519044715_RemoveTags.cs
Normal file
115
src/ClaudeDo.Data/Migrations/20260519044715_RemoveTags.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RemoveTags : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "list_tags");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "task_tags");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "tags");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "tags",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
name = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_tags", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "list_tags",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
list_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_list_tags", x => new { x.list_id, x.tag_id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_list_tags_lists_list_id",
|
||||||
|
column: x => x.list_id,
|
||||||
|
principalTable: "lists",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_list_tags_tags_tag_id",
|
||||||
|
column: x => x.tag_id,
|
||||||
|
principalTable: "tags",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "task_tags",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_task_tags", x => new { x.task_id, x.tag_id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_task_tags_tags_tag_id",
|
||||||
|
column: x => x.tag_id,
|
||||||
|
principalTable: "tags",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_task_tags_tasks_task_id",
|
||||||
|
column: x => x.task_id,
|
||||||
|
principalTable: "tasks",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "tags",
|
||||||
|
columns: new[] { "id", "name" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1L, "agent" },
|
||||||
|
{ 2L, "manual" }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_list_tags_tag_id",
|
||||||
|
table: "list_tags",
|
||||||
|
column: "tag_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_tags_name",
|
||||||
|
table: "tags",
|
||||||
|
column: "name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_task_tags_tag_id",
|
||||||
|
table: "task_tags",
|
||||||
|
column: "tag_id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -230,38 +230,6 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
b.ToTable("subtasks", (string)null);
|
b.ToTable("subtasks", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<long>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("name");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Name")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("tags", (string)null);
|
|
||||||
|
|
||||||
b.HasData(
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 1L,
|
|
||||||
Name = "agent"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 2L,
|
|
||||||
Name = "manual"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -526,36 +494,6 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
b.ToTable("worktrees", (string)null);
|
b.ToTable("worktrees", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("list_tags", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("list_id")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<long>("tag_id")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.HasKey("list_id", "tag_id");
|
|
||||||
|
|
||||||
b.HasIndex("tag_id");
|
|
||||||
|
|
||||||
b.ToTable("list_tags", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("task_tags", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("task_id")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<long>("tag_id")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.HasKey("task_id", "tag_id");
|
|
||||||
|
|
||||||
b.HasIndex("tag_id");
|
|
||||||
|
|
||||||
b.ToTable("task_tags", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||||
@@ -623,36 +561,6 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
b.Navigation("Task");
|
b.Navigation("Task");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("list_tags", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("list_id")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("tag_id")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("task_tags", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("tag_id")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("task_id")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Config");
|
b.Navigation("Config");
|
||||||
|
|||||||
11
src/ClaudeDo.Data/Models/CommitTypeRegistry.cs
Normal file
11
src/ClaudeDo.Data/Models/CommitTypeRegistry.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public static class CommitTypeRegistry
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<string> Types = new[]
|
||||||
|
{
|
||||||
|
"chore", "feat", "fix", "refactor", "docs", "test", "ci", "perf", "style", "build",
|
||||||
|
};
|
||||||
|
|
||||||
|
public const string DefaultType = "chore";
|
||||||
|
}
|
||||||
@@ -6,10 +6,9 @@ public sealed class ListEntity
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public required DateTime CreatedAt { get; init; }
|
public required DateTime CreatedAt { get; init; }
|
||||||
public string? WorkingDir { get; set; }
|
public string? WorkingDir { get; set; }
|
||||||
public string DefaultCommitType { get; set; } = "chore";
|
public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
|
||||||
|
|
||||||
// Navigation properties
|
// Navigation properties
|
||||||
public ListConfigEntity? Config { get; set; }
|
public ListConfigEntity? Config { get; set; }
|
||||||
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
|
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
|
||||||
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
|
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/ClaudeDo.Data/Models/ModelRegistry.cs
Normal file
12
src/ClaudeDo.Data/Models/ModelRegistry.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public static class ModelRegistry
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" };
|
||||||
|
|
||||||
|
public const string DefaultAlias = "sonnet";
|
||||||
|
public const string PlanningAlias = "opus";
|
||||||
|
|
||||||
|
public const string ListDefaultSentinel = "(default)";
|
||||||
|
public const string TaskInheritSentinel = "(inherit)";
|
||||||
|
}
|
||||||
11
src/ClaudeDo.Data/Models/PermissionModeRegistry.cs
Normal file
11
src/ClaudeDo.Data/Models/PermissionModeRegistry.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public static class PermissionModeRegistry
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<string> Modes = new[]
|
||||||
|
{
|
||||||
|
"auto", "bypassPermissions", "acceptEdits", "plan", "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
public const string DefaultMode = "auto";
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace ClaudeDo.Data.Models;
|
|
||||||
|
|
||||||
public sealed class TagEntity
|
|
||||||
{
|
|
||||||
public long Id { get; init; }
|
|
||||||
public required string Name { get; set; }
|
|
||||||
|
|
||||||
// Navigation properties
|
|
||||||
public ICollection<ListEntity> Lists { get; set; } = new List<ListEntity>();
|
|
||||||
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@ public sealed class TaskEntity
|
|||||||
public required DateTime CreatedAt { get; init; }
|
public required DateTime CreatedAt { get; init; }
|
||||||
public DateTime? StartedAt { get; set; }
|
public DateTime? StartedAt { get; set; }
|
||||||
public DateTime? FinishedAt { get; set; }
|
public DateTime? FinishedAt { get; set; }
|
||||||
public string CommitType { get; set; } = "chore";
|
public string CommitType { get; set; } = CommitTypeRegistry.DefaultType;
|
||||||
public string? Model { get; set; }
|
public string? Model { get; set; }
|
||||||
public string? SystemPrompt { get; set; }
|
public string? SystemPrompt { get; set; }
|
||||||
public string? AgentPath { get; set; }
|
public string? AgentPath { get; set; }
|
||||||
@@ -51,7 +51,6 @@ public sealed class TaskEntity
|
|||||||
// 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<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>();
|
||||||
|
|
||||||
|
|||||||
18
src/ClaudeDo.Data/Repositories/DiscardPlanningOutcome.cs
Normal file
18
src/ClaudeDo.Data/Repositories/DiscardPlanningOutcome.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ClaudeDo.Data.Repositories;
|
||||||
|
|
||||||
|
public enum DiscardPlanningResult
|
||||||
|
{
|
||||||
|
/// <summary>Planning state cleared, children handled.</summary>
|
||||||
|
Discarded,
|
||||||
|
/// <summary>Parent not found or not in <c>PlanningPhase.Active</c>.</summary>
|
||||||
|
NotInPlanning,
|
||||||
|
/// <summary>At least one child is <c>Queued</c> and the caller did not opt in to auto-dequeue.</summary>
|
||||||
|
BlockedByQueuedChildren,
|
||||||
|
/// <summary>At least one child is <c>Running</c>; user must cancel it before discarding.</summary>
|
||||||
|
BlockedByRunningChildren,
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct DiscardPlanningOutcome(
|
||||||
|
DiscardPlanningResult Result,
|
||||||
|
int QueuedChildrenCount,
|
||||||
|
int RunningChildrenCount);
|
||||||
@@ -36,38 +36,6 @@ public sealed class ListRepository
|
|||||||
return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
|
return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return await _context.Lists
|
|
||||||
.Where(l => l.Id == listId)
|
|
||||||
.SelectMany(l => l.Tags)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
|
|
||||||
if (list is null) return;
|
|
||||||
var tag = await _context.Tags.FindAsync([tagId], ct);
|
|
||||||
if (tag is not null && !list.Tags.Any(t => t.Id == tagId))
|
|
||||||
{
|
|
||||||
list.Tags.Add(tag);
|
|
||||||
await _context.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
|
|
||||||
if (list is null) return;
|
|
||||||
var tag = list.Tags.FirstOrDefault(t => t.Id == tagId);
|
|
||||||
if (tag is not null)
|
|
||||||
{
|
|
||||||
list.Tags.Remove(tag);
|
|
||||||
await _context.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
|
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
|
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
using ClaudeDo.Data.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Repositories;
|
|
||||||
|
|
||||||
public sealed class TagRepository
|
|
||||||
{
|
|
||||||
private readonly ClaudeDoDbContext _context;
|
|
||||||
|
|
||||||
public TagRepository(ClaudeDoDbContext context) => _context = context;
|
|
||||||
|
|
||||||
public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
|
|
||||||
if (existing is not null)
|
|
||||||
return existing.Id;
|
|
||||||
|
|
||||||
var tag = new TagEntity { Name = name };
|
|
||||||
_context.Tags.Add(tag);
|
|
||||||
await _context.SaveChangesAsync(ct);
|
|
||||||
return tag.Id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -171,74 +171,6 @@ public sealed class TaskRepository
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Tags
|
|
||||||
|
|
||||||
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
|
||||||
if (task is null) return;
|
|
||||||
var tag = await _context.Tags.FindAsync([tagId], ct);
|
|
||||||
if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
|
|
||||||
{
|
|
||||||
task.Tags.Add(tag);
|
|
||||||
await _context.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
|
||||||
if (task is null) return;
|
|
||||||
var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
|
|
||||||
if (tag is not null)
|
|
||||||
{
|
|
||||||
task.Tags.Remove(tag);
|
|
||||||
await _context.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SetTagsAsync(string taskId, IReadOnlyList<string> tagNames, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
|
||||||
if (task is null) return;
|
|
||||||
|
|
||||||
task.Tags.Clear();
|
|
||||||
|
|
||||||
foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
|
|
||||||
if (tag is null)
|
|
||||||
{
|
|
||||||
tag = new TagEntity { Name = name };
|
|
||||||
_context.Tags.Add(tag);
|
|
||||||
}
|
|
||||||
task.Tags.Add(tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _context.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return await _context.Tasks
|
|
||||||
.Where(t => t.Id == taskId)
|
|
||||||
.SelectMany(t => t.Tags)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var taskTags = _context.Tasks
|
|
||||||
.Where(t => t.Id == taskId)
|
|
||||||
.SelectMany(t => t.Tags);
|
|
||||||
var listTags = _context.Tasks
|
|
||||||
.Where(t => t.Id == taskId)
|
|
||||||
.SelectMany(t => t.List.Tags);
|
|
||||||
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Planning
|
#region Planning
|
||||||
|
|
||||||
public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default)
|
public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default)
|
||||||
@@ -254,13 +186,18 @@ public sealed class TaskRepository
|
|||||||
string parentId,
|
string parentId,
|
||||||
string title,
|
string title,
|
||||||
string? description,
|
string? description,
|
||||||
IReadOnlyList<string>? tagNames,
|
|
||||||
string? commitType,
|
string? commitType,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
// AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
|
||||||
|
// bypasses the change tracker; a tracked Find would return stale data.
|
||||||
|
var parent = await _context.Tasks.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
if (parent is null)
|
if (parent is null)
|
||||||
throw new InvalidOperationException($"Parent task {parentId} not found.");
|
throw new InvalidOperationException($"Parent task {parentId} not found.");
|
||||||
|
if (parent.PlanningPhase == PlanningPhase.None)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Parent task {parentId} is not in a planning phase; cannot attach children.");
|
||||||
|
|
||||||
var maxSort = await _context.Tasks
|
var maxSort = await _context.Tasks
|
||||||
.Where(t => t.ListId == parent.ListId)
|
.Where(t => t.ListId == parent.ListId)
|
||||||
@@ -280,22 +217,6 @@ public sealed class TaskRepository
|
|||||||
SortOrder = (maxSort ?? -1) + 1,
|
SortOrder = (maxSort ?? -1) + 1,
|
||||||
};
|
};
|
||||||
_context.Tasks.Add(child);
|
_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);
|
await _context.SaveChangesAsync(ct);
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
@@ -305,11 +226,10 @@ public sealed class TaskRepository
|
|||||||
string? title,
|
string? title,
|
||||||
string? description,
|
string? description,
|
||||||
string? commitType,
|
string? commitType,
|
||||||
IReadOnlyList<string>? tagNames,
|
|
||||||
TaskStatus? status,
|
TaskStatus? status,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct)
|
var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, ct)
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
|
||||||
if (title is not null) task.Title = title;
|
if (title is not null) task.Title = title;
|
||||||
@@ -317,21 +237,6 @@ public sealed class TaskRepository
|
|||||||
if (commitType is not null) task.CommitType = commitType;
|
if (commitType is not null) task.CommitType = commitType;
|
||||||
if (status.HasValue) task.Status = status.Value;
|
if (status.HasValue) task.Status = status.Value;
|
||||||
|
|
||||||
if (tagNames is not null)
|
|
||||||
{
|
|
||||||
task.Tags.Clear();
|
|
||||||
foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
|
|
||||||
if (tag is null)
|
|
||||||
{
|
|
||||||
tag = new TagEntity { Name = name };
|
|
||||||
_context.Tags.Add(tag);
|
|
||||||
}
|
|
||||||
task.Tags.Add(tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,8 +306,9 @@ public sealed class TaskRepository
|
|||||||
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> DiscardPlanningAsync(
|
public async Task<DiscardPlanningOutcome> DiscardPlanningAsync(
|
||||||
string parentId,
|
string parentId,
|
||||||
|
bool dequeueQueuedChildren,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||||||
@@ -413,10 +319,42 @@ public sealed class TaskRepository
|
|||||||
if (parent is null || parent.PlanningPhase != PlanningPhase.Active)
|
if (parent is null || parent.PlanningPhase != PlanningPhase.Active)
|
||||||
{
|
{
|
||||||
await tx.RollbackAsync(ct);
|
await tx.RollbackAsync(ct);
|
||||||
return false;
|
return new DiscardPlanningOutcome(DiscardPlanningResult.NotInPlanning, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Children created during the planning session are Status=Idle, PlanningPhase=None.
|
var children = await _context.Tasks
|
||||||
|
.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.Select(t => new { t.Id, t.Status })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var runningCount = children.Count(c => c.Status == TaskStatus.Running);
|
||||||
|
if (runningCount > 0)
|
||||||
|
{
|
||||||
|
await tx.RollbackAsync(ct);
|
||||||
|
return new DiscardPlanningOutcome(DiscardPlanningResult.BlockedByRunningChildren, 0, runningCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
var queuedIds = children.Where(c => c.Status == TaskStatus.Queued).Select(c => c.Id).ToList();
|
||||||
|
if (queuedIds.Count > 0)
|
||||||
|
{
|
||||||
|
if (!dequeueQueuedChildren)
|
||||||
|
{
|
||||||
|
await tx.RollbackAsync(ct);
|
||||||
|
return new DiscardPlanningOutcome(DiscardPlanningResult.BlockedByQueuedChildren, queuedIds.Count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => queuedIds.Contains(t.Id))
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Idle)
|
||||||
|
.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal children (Done/Failed/Cancelled) stay attached to the parent even
|
||||||
|
// though its PlanningPhase will be reset to None. The lineage is preserved as
|
||||||
|
// historical context; the UI nests them under their parent regardless of phase.
|
||||||
|
|
||||||
|
// Idle children created during this planning session are dropped.
|
||||||
await _context.Tasks
|
await _context.Tasks
|
||||||
.Where(t => t.ParentTaskId == parentId
|
.Where(t => t.ParentTaskId == parentId
|
||||||
&& t.Status == TaskStatus.Idle
|
&& t.Status == TaskStatus.Idle
|
||||||
@@ -433,7 +371,96 @@ public sealed class TaskRepository
|
|||||||
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
|
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
|
||||||
|
|
||||||
await tx.CommitAsync(ct);
|
await tx.CommitAsync(ct);
|
||||||
return true;
|
return new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, queuedIds.Count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dequeues child tasks whose parent is missing or no longer in a planning phase:
|
||||||
|
/// sets <c>Status</c> from <c>Queued</c> to <c>Idle</c> and clears
|
||||||
|
/// <c>BlockedByTaskId</c>. <c>ParentTaskId</c> stays intact — the child remains
|
||||||
|
/// part of its (former) planning chain for historical context. Returns the
|
||||||
|
/// number of rows dequeued. Idempotent.
|
||||||
|
/// </summary>
|
||||||
|
internal async Task<int> DequeueOrphanedChildrenAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var orphanIds = await _context.Tasks
|
||||||
|
.Where(t => t.ParentTaskId != null && t.Status == TaskStatus.Queued)
|
||||||
|
.Where(t => !_context.Tasks.Any(p =>
|
||||||
|
p.Id == t.ParentTaskId && p.PlanningPhase != PlanningPhase.None))
|
||||||
|
.Select(t => t.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (orphanIds.Count == 0) return 0;
|
||||||
|
|
||||||
|
return await _context.Tasks
|
||||||
|
.Where(t => orphanIds.Contains(t.Id))
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Idle)
|
||||||
|
.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restores a planning-session lineage that lost its <c>parent_task_id</c> links.
|
||||||
|
/// Given a candidate parent task and a single unambiguous orphan chain in the
|
||||||
|
/// same list (linked via <c>BlockedByTaskId</c>), re-attaches the chain members
|
||||||
|
/// to the parent, marks the parent as <c>Finalized</c>, and dequeues queued
|
||||||
|
/// chain members. No-op if conditions are not met. Returns the number of
|
||||||
|
/// re-attached children (0 if skipped).
|
||||||
|
/// </summary>
|
||||||
|
internal async Task<int> RestorePlanningLineageAsync(string parentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var parent = await _context.Tasks.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null) return 0;
|
||||||
|
if (parent.PlanningPhase != PlanningPhase.None) return 0;
|
||||||
|
if (parent.Status is TaskStatus.Done or TaskStatus.Failed or TaskStatus.Cancelled) return 0;
|
||||||
|
|
||||||
|
// Candidates: unattached tasks in the same list, excluding the parent itself.
|
||||||
|
var candidates = await _context.Tasks.AsNoTracking()
|
||||||
|
.Where(t => t.ListId == parent.ListId && t.ParentTaskId == null && t.Id != parent.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// A chain is a maximal linear sequence linked via BlockedByTaskId. Find heads
|
||||||
|
// (BlockedByTaskId == null) that have at least one successor.
|
||||||
|
var bySource = candidates
|
||||||
|
.Where(c => c.BlockedByTaskId != null)
|
||||||
|
.ToLookup(c => c.BlockedByTaskId!);
|
||||||
|
|
||||||
|
var heads = candidates
|
||||||
|
.Where(c => c.BlockedByTaskId == null && bySource[c.Id].Any())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Bail unless exactly one chain anchors a successor — anything else is
|
||||||
|
// ambiguous and we refuse to guess.
|
||||||
|
if (heads.Count != 1) return 0;
|
||||||
|
|
||||||
|
var chain = new List<TaskEntity> { heads[0] };
|
||||||
|
var current = heads[0];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var next = bySource[current.Id].FirstOrDefault();
|
||||||
|
if (next is null) break;
|
||||||
|
chain.Add(next);
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chainIds = chain.Select(c => c.Id).ToList();
|
||||||
|
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized), ct);
|
||||||
|
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => chainIds.Contains(t.Id))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, (string?)parentId), ct);
|
||||||
|
|
||||||
|
// Dequeue queued chain members; blocked_by stays intact so chain order is
|
||||||
|
// preserved for manual re-queueing.
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => chainIds.Contains(t.Id) && t.Status == TaskStatus.Queued)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Idle), ct);
|
||||||
|
|
||||||
|
return chainIds.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task TryCompleteParentAsync(
|
public async Task TryCompleteParentAsync(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||||
|
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
24
src/ClaudeDo.Ui/Converters/DiffLineKindToBrushConverter.cs
Normal file
24
src/ClaudeDo.Ui/Converters/DiffLineKindToBrushConverter.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class DiffLineKindToBrushConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
|
value is WorktreeDiffLineKind kind
|
||||||
|
? kind switch
|
||||||
|
{
|
||||||
|
WorktreeDiffLineKind.Added => new SolidColorBrush(Color.Parse("#66BB6A")),
|
||||||
|
WorktreeDiffLineKind.Removed => new SolidColorBrush(Color.Parse("#EF5350")),
|
||||||
|
WorktreeDiffLineKind.Hunk => new SolidColorBrush(Color.Parse("#42A5F5")),
|
||||||
|
WorktreeDiffLineKind.Header => new SolidColorBrush(Color.Parse("#9E9E9E")),
|
||||||
|
_ => new SolidColorBrush(Color.Parse("#CFD8DC")),
|
||||||
|
}
|
||||||
|
: new SolidColorBrush(Color.Parse("#CFD8DC"));
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
24
src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs
Normal file
24
src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class WorktreeStateColorConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
|
value is WorktreeState state
|
||||||
|
? state switch
|
||||||
|
{
|
||||||
|
WorktreeState.Active => new SolidColorBrush(Color.Parse("#42A5F5")),
|
||||||
|
WorktreeState.Merged => new SolidColorBrush(Color.Parse("#66BB6A")),
|
||||||
|
WorktreeState.Discarded => new SolidColorBrush(Color.Parse("#9E9E9E")),
|
||||||
|
WorktreeState.Kept => new SolidColorBrush(Color.Parse("#FFA726")),
|
||||||
|
_ => new SolidColorBrush(Colors.Gray),
|
||||||
|
}
|
||||||
|
: new SolidColorBrush(Colors.Gray);
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Services;
|
namespace ClaudeDo.Ui.Services;
|
||||||
@@ -11,6 +12,8 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
event Action<string, string, DateTime>? TaskStartedEvent;
|
event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
event Action<string>? TaskUpdatedEvent;
|
event Action<string>? TaskUpdatedEvent;
|
||||||
|
/// <summary>Raised once when the SignalR connection is first established, and again on every reconnect.</summary>
|
||||||
|
event Action? ConnectionRestoredEvent;
|
||||||
event Action<string>? WorktreeUpdatedEvent;
|
event Action<string>? WorktreeUpdatedEvent;
|
||||||
event Action<string, string>? TaskMessageEvent;
|
event Action<string, string>? TaskMessageEvent;
|
||||||
|
|
||||||
@@ -29,12 +32,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||||
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||||
Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames);
|
|
||||||
Task<List<string>> GetAllTagsAsync();
|
|
||||||
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
||||||
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default);
|
||||||
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
|
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
|
||||||
Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
|
Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
|
||||||
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
|
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Services;
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string>? RunNowRequestedEvent;
|
public event Action<string>? RunNowRequestedEvent;
|
||||||
public event Action<string>? ListUpdatedEvent;
|
public event Action<string>? ListUpdatedEvent;
|
||||||
@@ -64,12 +67,17 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
_hub = new HubConnectionBuilder()
|
_hub = new HubConnectionBuilder()
|
||||||
.WithUrl(signalRUrl)
|
.WithUrl(signalRUrl)
|
||||||
.WithAutomaticReconnect(new IndefiniteRetryPolicy())
|
.WithAutomaticReconnect(new IndefiniteRetryPolicy())
|
||||||
|
.AddJsonProtocol(options =>
|
||||||
|
{
|
||||||
|
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||||
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
_hub.Reconnected += async _ =>
|
_hub.Reconnected += async _ =>
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
|
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
|
||||||
await SeedActiveTasksAsync();
|
await SeedActiveTasksAsync();
|
||||||
|
Dispatcher.UIThread.Post(() => ConnectionRestoredEvent?.Invoke());
|
||||||
};
|
};
|
||||||
|
|
||||||
_hub.Reconnecting += _ =>
|
_hub.Reconnecting += _ =>
|
||||||
@@ -194,6 +202,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
await _hub.StartAsync(ct);
|
await _hub.StartAsync(ct);
|
||||||
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
|
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
|
||||||
await SeedActiveTasksAsync();
|
await SeedActiveTasksAsync();
|
||||||
|
Dispatcher.UIThread.Post(() => ConnectionRestoredEvent?.Invoke());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -386,28 +395,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames)
|
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
|
||||||
{
|
|
||||||
await _hub.InvokeAsync("SetTaskTags", taskId, tagNames.ToArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<string>> GetAllTagsAsync()
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _hub.InvokeAsync<List<string>>("GetAllTags") ?? new List<string>();
|
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return new List<string>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees");
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -427,6 +419,43 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rows = await _hub.InvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId);
|
||||||
|
return rows ?? new List<WorktreeOverviewDto>();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new List<WorktreeOverviewDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetWorktreeStateAsync(string taskId, WorktreeState newState)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
|
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
@@ -436,8 +465,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public async Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default)
|
public async Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync("OpenInteractiveTerminalAsync", taskId, ct);
|
=> await _hub.InvokeAsync("OpenInteractiveTerminalAsync", taskId, ct);
|
||||||
|
|
||||||
public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
|
=> await _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
|
||||||
|
|
||||||
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
|
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
|
||||||
@@ -496,8 +525,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
=> await StartPlanningSessionAsync(taskId, ct);
|
=> await StartPlanningSessionAsync(taskId, ct);
|
||||||
async Task IWorkerClient.ResumePlanningSessionAsync(string taskId, CancellationToken ct)
|
async Task IWorkerClient.ResumePlanningSessionAsync(string taskId, CancellationToken ct)
|
||||||
=> await ResumePlanningSessionAsync(taskId, ct);
|
=> await ResumePlanningSessionAsync(taskId, ct);
|
||||||
async Task IWorkerClient.DiscardPlanningSessionAsync(string taskId, CancellationToken ct)
|
async Task<DiscardPlanningOutcome> IWorkerClient.DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren, CancellationToken ct)
|
||||||
=> await DiscardPlanningSessionAsync(taskId, ct);
|
=> await DiscardPlanningSessionAsync(taskId, dequeueQueuedChildren, ct);
|
||||||
async Task IWorkerClient.FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
async Task IWorkerClient.FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
||||||
=> await FinalizePlanningSessionAsync(taskId, queueAgentTasks, ct);
|
=> await FinalizePlanningSessionAsync(taskId, queueAgentTasks, ct);
|
||||||
async Task<int> IWorkerClient.GetPendingDraftCountAsync(string taskId, CancellationToken ct)
|
async Task<int> IWorkerClient.GetPendingDraftCountAsync(string taskId, CancellationToken ct)
|
||||||
@@ -531,3 +560,19 @@ public sealed record UpdateListConfigDto(string ListId, string? Model, string? S
|
|||||||
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
|
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
|
||||||
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
|
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
|
||||||
public sealed record SeedResultDto(int Copied, int Skipped);
|
public sealed record SeedResultDto(int Copied, int Skipped);
|
||||||
|
|
||||||
|
public sealed record WorktreeOverviewDto(
|
||||||
|
string TaskId,
|
||||||
|
string TaskTitle,
|
||||||
|
ClaudeDo.Data.Models.TaskStatus TaskStatus,
|
||||||
|
string ListId,
|
||||||
|
string ListName,
|
||||||
|
string Path,
|
||||||
|
string BranchName,
|
||||||
|
string BaseCommit,
|
||||||
|
WorktreeState State,
|
||||||
|
string? DiffStat,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
bool PathExistsOnDisk);
|
||||||
|
|
||||||
|
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
// Current task row (set by IslandsShellViewModel via Bind)
|
// Current task row (set by IslandsShellViewModel via Bind)
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
|
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
|
||||||
private TaskRowViewModel? _task;
|
private TaskRowViewModel? _task;
|
||||||
|
|
||||||
// Editable fields
|
// Editable fields
|
||||||
@@ -56,74 +58,23 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
// Short task-id badge, e.g. "#T1A"
|
// Short task-id badge, e.g. "#T1A"
|
||||||
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
||||||
|
|
||||||
// Agent strip fields
|
|
||||||
// Status editor (Details panel) — set freely; broadcast refreshes other panes.
|
|
||||||
public System.Collections.ObjectModel.ObservableCollection<ClaudeDo.Data.Models.TaskStatus> StatusOptions { get; } = new()
|
|
||||||
{
|
|
||||||
ClaudeDo.Data.Models.TaskStatus.Idle,
|
|
||||||
ClaudeDo.Data.Models.TaskStatus.Queued,
|
|
||||||
ClaudeDo.Data.Models.TaskStatus.Running,
|
|
||||||
ClaudeDo.Data.Models.TaskStatus.Done,
|
|
||||||
ClaudeDo.Data.Models.TaskStatus.Failed,
|
|
||||||
ClaudeDo.Data.Models.TaskStatus.Cancelled,
|
|
||||||
};
|
|
||||||
|
|
||||||
private bool _suppressStatusSave;
|
|
||||||
[ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _selectedStatus;
|
|
||||||
|
|
||||||
partial void OnSelectedStatusChanged(ClaudeDo.Data.Models.TaskStatus value)
|
|
||||||
{
|
|
||||||
if (_suppressStatusSave || Task is null) return;
|
|
||||||
_ = SaveStatusAsync(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task SaveStatusAsync(ClaudeDo.Data.Models.TaskStatus value)
|
|
||||||
{
|
|
||||||
if (Task is null) return;
|
|
||||||
try { await _worker.SetTaskStatusAsync(Task.Id, value); }
|
|
||||||
catch { /* offline */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tag editor
|
|
||||||
public ObservableCollection<string> Tags { get; } = new();
|
|
||||||
public ObservableCollection<string> AvailableTags { get; } = new();
|
|
||||||
[ObservableProperty] private string _newTagInput = "";
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async System.Threading.Tasks.Task AddTagAsync()
|
|
||||||
{
|
|
||||||
if (Task is null) return;
|
|
||||||
var name = NewTagInput?.Trim().ToLowerInvariant();
|
|
||||||
NewTagInput = "";
|
|
||||||
if (string.IsNullOrEmpty(name)) return;
|
|
||||||
if (Tags.Contains(name)) return;
|
|
||||||
var next = Tags.ToList();
|
|
||||||
next.Add(name);
|
|
||||||
try { await _worker.SetTaskTagsAsync(Task.Id, next); }
|
|
||||||
catch { /* offline */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async System.Threading.Tasks.Task RemoveTagAsync(string? tagName)
|
|
||||||
{
|
|
||||||
if (Task is null || string.IsNullOrWhiteSpace(tagName)) return;
|
|
||||||
if (!Tags.Contains(tagName)) return;
|
|
||||||
var next = Tags.Where(t => t != tagName).ToList();
|
|
||||||
try { await _worker.SetTaskTagsAsync(Task.Id, next); }
|
|
||||||
catch { /* offline */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
|
|
||||||
private string _agentStatusLabel = "Idle";
|
|
||||||
public bool IsRunning => AgentStatusLabel == "Running";
|
|
||||||
public bool IsDone => AgentStatusLabel == "Done";
|
|
||||||
public bool IsFailed => AgentStatusLabel == "Failed";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ResetCommand))]
|
private string _agentStatusLabel = "Idle";
|
||||||
private bool _showFailedActions;
|
public bool IsIdle => AgentStatusLabel == "Idle";
|
||||||
|
public bool IsQueued => AgentStatusLabel == "Queued";
|
||||||
|
public bool IsRunning => AgentStatusLabel == "Running";
|
||||||
|
public bool IsDone => AgentStatusLabel == "Done";
|
||||||
|
public bool IsFailed => AgentStatusLabel == "Failed";
|
||||||
|
public bool IsCancelled => AgentStatusLabel == "Cancelled";
|
||||||
|
|
||||||
|
// Recovery actions: Continue (resume session) for Failed/Cancelled.
|
||||||
|
public bool ShowContinue => IsFailed || IsCancelled;
|
||||||
|
// Reset & retry available from any terminal state.
|
||||||
|
public bool ShowResetAndRetry => IsFailed || IsCancelled || IsDone;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||||
@@ -131,16 +82,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
partial void OnAgentStatusLabelChanged(string value)
|
partial void OnAgentStatusLabelChanged(string value)
|
||||||
{
|
{
|
||||||
|
OnPropertyChanged(nameof(IsIdle));
|
||||||
|
OnPropertyChanged(nameof(IsQueued));
|
||||||
OnPropertyChanged(nameof(IsRunning));
|
OnPropertyChanged(nameof(IsRunning));
|
||||||
OnPropertyChanged(nameof(IsDone));
|
OnPropertyChanged(nameof(IsDone));
|
||||||
OnPropertyChanged(nameof(IsFailed));
|
OnPropertyChanged(nameof(IsFailed));
|
||||||
|
OnPropertyChanged(nameof(IsCancelled));
|
||||||
|
OnPropertyChanged(nameof(ShowContinue));
|
||||||
|
OnPropertyChanged(nameof(ShowResetAndRetry));
|
||||||
OnPropertyChanged(nameof(IsAgentSectionEnabled));
|
OnPropertyChanged(nameof(IsAgentSectionEnabled));
|
||||||
ShowFailedActions = value == "Failed";
|
|
||||||
}
|
}
|
||||||
[ObservableProperty] private string? _model;
|
[ObservableProperty] private string? _model;
|
||||||
|
|
||||||
// Agent settings overrides
|
// Agent settings overrides
|
||||||
[ObservableProperty] private string _taskModelSelection = "(inherit)";
|
[ObservableProperty] private string _taskModelSelection = ModelRegistry.TaskInheritSentinel;
|
||||||
[ObservableProperty] private string _taskSystemPrompt = "";
|
[ObservableProperty] private string _taskSystemPrompt = "";
|
||||||
[ObservableProperty] private AgentInfo? _taskSelectedAgent;
|
[ObservableProperty] private AgentInfo? _taskSelectedAgent;
|
||||||
|
|
||||||
@@ -148,10 +103,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
||||||
[ObservableProperty] private string _effectiveAgentHint = "";
|
[ObservableProperty] private string _effectiveAgentHint = "";
|
||||||
|
|
||||||
public System.Collections.ObjectModel.ObservableCollection<string> TaskModelOptions { get; } = new()
|
public System.Collections.ObjectModel.ObservableCollection<string> TaskModelOptions { get; } = new(
|
||||||
{
|
new[] { ModelRegistry.TaskInheritSentinel }.Concat(ModelRegistry.Aliases));
|
||||||
"(inherit)", "sonnet", "opus", "haiku",
|
|
||||||
};
|
|
||||||
|
|
||||||
public System.Collections.ObjectModel.ObservableCollection<AgentInfo> TaskAgentOptions { get; } = new();
|
public System.Collections.ObjectModel.ObservableCollection<AgentInfo> TaskAgentOptions { get; } = new();
|
||||||
|
|
||||||
@@ -237,40 +190,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
// Set by the view so DeleteTaskCommand can show an error message
|
// Set by the view so DeleteTaskCommand can show an error message
|
||||||
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
||||||
|
|
||||||
private void ApplyTagsFromEntity(ClaudeDo.Data.Models.TaskEntity entity)
|
private async System.Threading.Tasks.Task RefreshStatusAsync(string taskId)
|
||||||
{
|
|
||||||
Tags.Clear();
|
|
||||||
foreach (var t in entity.Tags) Tags.Add(t.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task RefreshAvailableTagsAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var all = await _worker.GetAllTagsAsync();
|
|
||||||
AvailableTags.Clear();
|
|
||||||
foreach (var t in all) AvailableTags.Add(t);
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task RefreshTagsAndStatusAsync(string taskId)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
var entity = await ctx.Tasks
|
var entity = await ctx.Tasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Tags)
|
|
||||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||||
if (entity is null || Task?.Id != taskId) return;
|
if (entity is null || Task?.Id != taskId) return;
|
||||||
|
|
||||||
_suppressStatusSave = true;
|
|
||||||
try { SelectedStatus = entity.Status; }
|
|
||||||
finally { _suppressStatusSave = false; }
|
|
||||||
AgentStatusLabel = entity.Status.ToString();
|
AgentStatusLabel = entity.Status.ToString();
|
||||||
ApplyTagsFromEntity(entity);
|
|
||||||
await RefreshAvailableTagsAsync();
|
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
@@ -289,9 +219,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||||||
{
|
{
|
||||||
RunNowCommand.NotifyCanExecuteChanged();
|
EnqueueCommand.NotifyCanExecuteChanged();
|
||||||
|
DequeueCommand.NotifyCanExecuteChanged();
|
||||||
|
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||||
ContinueCommand.NotifyCanExecuteChanged();
|
ContinueCommand.NotifyCanExecuteChanged();
|
||||||
ResetCommand.NotifyCanExecuteChanged();
|
|
||||||
ApproveMergeCommand.NotifyCanExecuteChanged();
|
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -323,7 +254,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
_worker.TaskUpdatedEvent += taskId =>
|
_worker.TaskUpdatedEvent += taskId =>
|
||||||
{
|
{
|
||||||
if (Task?.Id == taskId) _ = RefreshTagsAndStatusAsync(taskId);
|
if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId);
|
||||||
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -432,7 +363,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
await System.Threading.Tasks.Task.Delay(300, ct);
|
await System.Threading.Tasks.Task.Delay(300, ct);
|
||||||
if (Task is null) return;
|
if (Task is null) return;
|
||||||
|
|
||||||
var model = TaskModelSelection == "(inherit)" ? null : TaskModelSelection;
|
var model = TaskModelSelection == ModelRegistry.TaskInheritSentinel ? null : TaskModelSelection;
|
||||||
var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
|
var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
|
||||||
var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
|
var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
|
||||||
? null : TaskSelectedAgent.Path;
|
? null : TaskSelectedAgent.Path;
|
||||||
@@ -451,11 +382,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
TaskAgentOptions.Clear();
|
TaskAgentOptions.Clear();
|
||||||
TaskAgentOptions.Add(new AgentInfo("(inherit)", "", ""));
|
TaskAgentOptions.Add(new AgentInfo(ModelRegistry.TaskInheritSentinel, "", ""));
|
||||||
var agents = await _worker.GetAgentsAsync();
|
var agents = await _worker.GetAgentsAsync();
|
||||||
foreach (var a in agents) TaskAgentOptions.Add(a);
|
foreach (var a in agents) TaskAgentOptions.Add(a);
|
||||||
|
|
||||||
TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? "(inherit)" : entity.Model!;
|
TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? ModelRegistry.TaskInheritSentinel : entity.Model!;
|
||||||
TaskSystemPrompt = entity.SystemPrompt ?? "";
|
TaskSystemPrompt = entity.SystemPrompt ?? "";
|
||||||
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
|
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
|
||||||
? TaskAgentOptions[0]
|
? TaskAgentOptions[0]
|
||||||
@@ -503,17 +434,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
BranchLine = null;
|
BranchLine = null;
|
||||||
AgentStatusLabel = "Idle";
|
AgentStatusLabel = "Idle";
|
||||||
LatestRunSessionId = null;
|
LatestRunSessionId = null;
|
||||||
ShowFailedActions = false;
|
|
||||||
Tags.Clear();
|
|
||||||
AvailableTags.Clear();
|
|
||||||
NewTagInput = "";
|
|
||||||
_suppressStatusSave = true;
|
|
||||||
try { SelectedStatus = ClaudeDo.Data.Models.TaskStatus.Idle; }
|
|
||||||
finally { _suppressStatusSave = false; }
|
|
||||||
_suppressAgentSave = true;
|
_suppressAgentSave = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
TaskModelSelection = "(inherit)";
|
TaskModelSelection = ModelRegistry.TaskInheritSentinel;
|
||||||
TaskSystemPrompt = "";
|
TaskSystemPrompt = "";
|
||||||
TaskSelectedAgent = null;
|
TaskSelectedAgent = null;
|
||||||
}
|
}
|
||||||
@@ -537,11 +461,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var subtaskRepo = new SubtaskRepository(ctx);
|
var subtaskRepo = new SubtaskRepository(ctx);
|
||||||
|
|
||||||
// Own query with Include so WorktreePath/BranchLine/Tags are populated.
|
// Own query with Include so WorktreePath/BranchLine are populated.
|
||||||
var entity = await ctx.Tasks
|
var entity = await ctx.Tasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
.Include(t => t.Tags)
|
|
||||||
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
if (entity == null) return;
|
if (entity == null) return;
|
||||||
@@ -557,11 +480,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||||||
AgentStatusLabel = entity.Status.ToString();
|
AgentStatusLabel = entity.Status.ToString();
|
||||||
_suppressStatusSave = true;
|
|
||||||
try { SelectedStatus = entity.Status; }
|
|
||||||
finally { _suppressStatusSave = false; }
|
|
||||||
ApplyTagsFromEntity(entity);
|
|
||||||
await RefreshAvailableTagsAsync();
|
|
||||||
await LoadAgentSettingsAsync(entity, ct);
|
await LoadAgentSettingsAsync(entity, ct);
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -926,24 +844,35 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
await _worker.CancelTaskAsync(Task.Id);
|
await _worker.CancelTaskAsync(Task.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanRunNow))]
|
[RelayCommand(CanExecute = nameof(CanEnqueue))]
|
||||||
private async System.Threading.Tasks.Task RunNowAsync()
|
private async System.Threading.Tasks.Task EnqueueAsync()
|
||||||
{
|
{
|
||||||
if (Task == null) return;
|
if (Task == null) return;
|
||||||
AgentStatusLabel = "Running";
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _worker.RunNowAsync(Task.Id);
|
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||||
}
|
AgentStatusLabel = "Queued";
|
||||||
catch
|
|
||||||
{
|
|
||||||
AgentStatusLabel = "Failed";
|
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
|
catch { /* offline */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanRunNow() =>
|
private bool CanEnqueue() =>
|
||||||
Task != null && _worker.IsConnected && !IsRunning;
|
Task != null && _worker.IsConnected && IsIdle;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanDequeue))]
|
||||||
|
private async System.Threading.Tasks.Task DequeueAsync()
|
||||||
|
{
|
||||||
|
if (Task == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Idle);
|
||||||
|
AgentStatusLabel = "Idle";
|
||||||
|
}
|
||||||
|
catch { /* offline */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanDequeue() =>
|
||||||
|
Task != null && _worker.IsConnected && IsQueued;
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanContinue))]
|
[RelayCommand(CanExecute = nameof(CanContinue))]
|
||||||
private async System.Threading.Tasks.Task ContinueAsync()
|
private async System.Threading.Tasks.Task ContinueAsync()
|
||||||
@@ -953,23 +882,32 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
private bool CanContinue() =>
|
private bool CanContinue() =>
|
||||||
Task != null && _worker.IsConnected && ShowFailedActions && !string.IsNullOrEmpty(LatestRunSessionId);
|
Task != null && _worker.IsConnected && ShowContinue && !string.IsNullOrEmpty(LatestRunSessionId);
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanReset))]
|
[RelayCommand(CanExecute = nameof(CanResetAndRetry))]
|
||||||
private async System.Threading.Tasks.Task ResetAsync()
|
private async System.Threading.Tasks.Task ResetAndRetryAsync()
|
||||||
{
|
{
|
||||||
if (Task == null) return;
|
if (Task == null) return;
|
||||||
if (ConfirmAsync == null) return;
|
if (ConfirmAsync == null) return;
|
||||||
|
|
||||||
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
||||||
var ok = await ConfirmAsync($"Discard worktree and reset task?\nThis deletes branch {branchName} and all uncommitted changes.");
|
var ok = await ConfirmAsync(
|
||||||
|
$"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning.");
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
await _worker.ResetTaskAsync(Task.Id);
|
if (WorktreePath != null)
|
||||||
|
await _worker.ResetTaskAsync(Task.Id);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||||
|
AgentStatusLabel = "Queued";
|
||||||
|
}
|
||||||
|
catch { /* offline */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanReset() =>
|
private bool CanResetAndRetry() =>
|
||||||
Task != null && _worker.IsConnected && ShowFailedActions;
|
Task != null && _worker.IsConnected && ShowResetAndRetry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class SubtaskRowViewModel : ViewModelBase
|
public sealed partial class SubtaskRowViewModel : ViewModelBase
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
@@ -10,7 +11,7 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private int _count;
|
[ObservableProperty] private int _count;
|
||||||
[ObservableProperty] private bool _isActive;
|
[ObservableProperty] private bool _isActive;
|
||||||
[ObservableProperty] private string? _workingDir;
|
[ObservableProperty] private string? _workingDir;
|
||||||
[ObservableProperty] private string _defaultCommitType = "chore";
|
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||||
public string? IconKey { get; init; }
|
public string? IconKey { get; init; }
|
||||||
public string? DotColorKey { get; init; }
|
public string? DotColorKey { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Filtering;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
@@ -19,6 +20,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly IServiceProvider? _services;
|
private readonly IServiceProvider? _services;
|
||||||
private readonly WorkerClient? _worker;
|
private readonly WorkerClient? _worker;
|
||||||
|
private static readonly TaskListFilterRegistry _filters = new();
|
||||||
|
|
||||||
public event EventHandler? SelectionChanged;
|
public event EventHandler? SelectionChanged;
|
||||||
public event EventHandler? FocusSearchRequested;
|
public event EventHandler? FocusSearchRequested;
|
||||||
@@ -26,6 +28,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
|
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
|
||||||
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
|
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
|
||||||
|
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task OpenSettings()
|
private async Task OpenSettings()
|
||||||
@@ -47,6 +50,18 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
await RefreshRowAsync(row.Id);
|
await RefreshRowAsync(row.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenWorktreesOverviewAsync(ListNavItemViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || ShowWorktreesOverviewModal is null || _services is null) return;
|
||||||
|
if (row.Kind != ListKind.User) return;
|
||||||
|
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||||
|
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
|
||||||
|
vm.Configure(rawId, row.Name);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
await ShowWorktreesOverviewModal(vm);
|
||||||
|
}
|
||||||
|
|
||||||
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
|
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
|
||||||
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
|
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
|
||||||
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
|
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
|
||||||
@@ -76,6 +91,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
|
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
|
||||||
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
||||||
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||||
|
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,38 +145,21 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
|
||||||
// Snapshot the open (non-Done) tasks once; small enough collection for client-side grouping.
|
// Single snapshot; counters and the list loader share the same filter strategies.
|
||||||
var open = await ctx.Tasks.AsNoTracking()
|
var all = await ctx.Tasks.AsNoTracking()
|
||||||
.Where(t => t.Status != TaskStatus.Done)
|
.Include(t => t.Worktree)
|
||||||
.Select(t => new { t.ListId, t.Status, t.IsMyDay, t.IsStarred, Scheduled = t.ScheduledFor })
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var running = open.Count(t => t.Status == TaskStatus.Running);
|
|
||||||
var queued = open.Count(t => t.Status == TaskStatus.Queued);
|
|
||||||
var review = await ctx.Tasks.AsNoTracking()
|
|
||||||
.Where(t => t.Status == TaskStatus.Done && t.Worktree != null && t.Worktree.State == WorktreeState.Active)
|
|
||||||
.CountAsync(ct);
|
|
||||||
|
|
||||||
foreach (var item in SmartLists)
|
foreach (var item in SmartLists)
|
||||||
{
|
{
|
||||||
item.Count = item.Id switch
|
var filter = _filters.Resolve(item.Id);
|
||||||
{
|
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
|
||||||
"smart:my-day" => open.Count(t => t.IsMyDay),
|
|
||||||
"smart:important" => open.Count(t => t.IsStarred),
|
|
||||||
"smart:planned" => open.Count(t => t.Scheduled != null),
|
|
||||||
"virtual:queued" => queued,
|
|
||||||
"virtual:running" => running,
|
|
||||||
"virtual:review" => review,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var item in UserLists)
|
foreach (var item in UserLists)
|
||||||
{
|
{
|
||||||
var listId = item.Id.StartsWith("user:", StringComparison.Ordinal)
|
var filter = _filters.Resolve(item.Id);
|
||||||
? item.Id["user:".Length..]
|
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
|
||||||
: item.Id;
|
|
||||||
item.Count = open.Count(t => t.ListId == listId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { throw; }
|
catch (OperationCanceledException) { throw; }
|
||||||
@@ -177,7 +176,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
Name = "New list",
|
Name = "New list",
|
||||||
DefaultCommitType = "chore",
|
DefaultCommitType = CommitTypeRegistry.DefaultType,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
@@ -8,11 +6,6 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
|||||||
|
|
||||||
public sealed partial class TaskRowViewModel : ViewModelBase
|
public sealed partial class TaskRowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public TaskRowViewModel()
|
|
||||||
{
|
|
||||||
Tags.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTags));
|
|
||||||
}
|
|
||||||
|
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
[ObservableProperty] private string _title = "";
|
[ObservableProperty] private string _title = "";
|
||||||
[ObservableProperty] private string _listName = "";
|
[ObservableProperty] private string _listName = "";
|
||||||
@@ -39,7 +32,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
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}";
|
||||||
|
|
||||||
public ObservableCollection<string> Tags { get; } = new();
|
|
||||||
public int StepsCount { get; init; }
|
public int StepsCount { get; init; }
|
||||||
public int StepsCompleted { get; init; }
|
public int StepsCompleted { get; init; }
|
||||||
|
|
||||||
@@ -62,13 +54,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
|
|
||||||
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 HasSteps => StepsCount > 0;
|
public bool HasSteps => StepsCount > 0;
|
||||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||||
public bool IsRunning => Status == TaskStatus.Running;
|
public bool IsRunning => Status == TaskStatus.Running;
|
||||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||||
|
public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks;
|
||||||
public bool HasSchedule => ScheduledFor.HasValue;
|
public bool HasSchedule => ScheduledFor.HasValue;
|
||||||
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||||
|
|
||||||
@@ -96,6 +88,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(IsDraft));
|
OnPropertyChanged(nameof(IsDraft));
|
||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||||
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnPlanningPhaseChanged(PlanningPhase value)
|
partial void OnPlanningPhaseChanged(PlanningPhase value)
|
||||||
@@ -107,7 +100,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
partial void OnHasQueuedSubtasksChanged(bool value)
|
partial void OnHasQueuedSubtasksChanged(bool value)
|
||||||
=> OnPropertyChanged(nameof(CanRemoveFromQueue));
|
{
|
||||||
|
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||||
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnBlockedByTaskIdChanged(string? value)
|
partial void OnBlockedByTaskIdChanged(string? value)
|
||||||
{
|
{
|
||||||
@@ -160,15 +156,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
DiffDeletions = del;
|
DiffDeletions = del;
|
||||||
ParentTaskId = t.ParentTaskId;
|
ParentTaskId = t.ParentTaskId;
|
||||||
BlockedByTaskId = t.BlockedByTaskId;
|
BlockedByTaskId = t.BlockedByTaskId;
|
||||||
SetTags(t.Tags.Select(tag => tag.Name));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetTags(IEnumerable<string> names)
|
|
||||||
{
|
|
||||||
var snapshot = names.ToList();
|
|
||||||
if (Tags.SequenceEqual(snapshot)) return;
|
|
||||||
Tags.Clear();
|
|
||||||
foreach (var n in snapshot) Tags.Add(n);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
|
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ using System.Globalization;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Filtering;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -18,6 +20,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
private readonly Dictionary<string, bool> _expandedState = new();
|
private readonly Dictionary<string, bool> _expandedState = new();
|
||||||
private ListNavItemViewModel? _currentList;
|
private ListNavItemViewModel? _currentList;
|
||||||
private CancellationTokenSource? _loadCts;
|
private CancellationTokenSource? _loadCts;
|
||||||
|
private static readonly TaskListFilterRegistry _filters = new();
|
||||||
|
|
||||||
public event EventHandler? SelectionChanged;
|
public event EventHandler? SelectionChanged;
|
||||||
public event EventHandler? FocusAddTaskRequested;
|
public event EventHandler? FocusAddTaskRequested;
|
||||||
@@ -28,7 +31,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
|
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
|
||||||
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
|
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
|
||||||
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
|
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
|
||||||
public ObservableCollection<string> AllTags { get; } = new();
|
|
||||||
|
|
||||||
[ObservableProperty] private string _newTaskTitle = "";
|
[ObservableProperty] private string _newTaskTitle = "";
|
||||||
[ObservableProperty] private TaskRowViewModel? _selectedTask;
|
[ObservableProperty] private TaskRowViewModel? _selectedTask;
|
||||||
@@ -52,25 +54,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
_worker = worker;
|
_worker = worker;
|
||||||
if (_worker is not null)
|
if (_worker is not null)
|
||||||
{
|
{
|
||||||
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
|
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
|
||||||
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
||||||
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
||||||
_ = RefreshAllTagsAsync();
|
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RefreshAllTagsAsync()
|
|
||||||
{
|
|
||||||
if (_worker is null) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var tags = await _worker.GetAllTagsAsync();
|
|
||||||
AllTags.Clear();
|
|
||||||
foreach (var t in tags) AllTags.Add(t);
|
|
||||||
}
|
|
||||||
catch { /* offline */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnWorkerTaskMessage(string taskId, string line)
|
private void OnWorkerTaskMessage(string taskId, string line)
|
||||||
{
|
{
|
||||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
||||||
@@ -97,7 +87,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
var entity = await db.Tasks
|
var entity = await db.Tasks
|
||||||
.Include(t => t.List)
|
.Include(t => t.List)
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
.Include(t => t.Tags)
|
|
||||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||||
|
|
||||||
var existing = Items.FirstOrDefault(r => r.Id == taskId);
|
var existing = Items.FirstOrDefault(r => r.Id == taskId);
|
||||||
@@ -186,31 +175,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
var all = await db.Tasks
|
var all = await db.Tasks
|
||||||
.Include(t => t.List)
|
.Include(t => t.List)
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
.Include(t => t.Tags)
|
|
||||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
static bool IsPlanningParent(TaskEntity t) => t.PlanningPhase != PlanningPhase.None;
|
var filter = _filters.Resolve(list.Id);
|
||||||
|
var filteredList = filter is null
|
||||||
IEnumerable<TaskEntity> filtered = list.Kind switch
|
? new List<TaskEntity>()
|
||||||
{
|
: all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList();
|
||||||
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
|
|
||||||
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
|
|
||||||
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
|
||||||
ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t =>
|
|
||||||
(t.Status == TaskStatus.Queued && t.ParentTaskId == null) ||
|
|
||||||
(IsPlanningParent(t) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Queued))),
|
|
||||||
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t =>
|
|
||||||
(t.Status == TaskStatus.Running && t.ParentTaskId == null) ||
|
|
||||||
(IsPlanningParent(t) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Running))),
|
|
||||||
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null),
|
|
||||||
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
|
|
||||||
_ => Enumerable.Empty<TaskEntity>(),
|
|
||||||
};
|
|
||||||
|
|
||||||
var filteredList = filtered.ToList();
|
|
||||||
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
|
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
|
||||||
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
|
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
|
||||||
foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!)))
|
foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!)))
|
||||||
@@ -282,17 +255,27 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
// Build hierarchy-aware flat list: top-level rows interleaved with visible children.
|
// Build hierarchy-aware flat list: top-level rows interleaved with visible children.
|
||||||
// Items is already ordered by SortOrder from the DB query.
|
// Items is already ordered by SortOrder from the DB query.
|
||||||
var topLevel = Items.Where(r => !r.IsChild);
|
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
|
||||||
|
var visibleIds = Items.Select(r => r.Id).ToHashSet();
|
||||||
|
bool IsTopLevel(TaskRowViewModel r) =>
|
||||||
|
!r.IsChild
|
||||||
|
|| string.IsNullOrEmpty(r.ParentTaskId)
|
||||||
|
|| !visibleIds.Contains(r.ParentTaskId!);
|
||||||
|
var topLevel = Items.Where(IsTopLevel);
|
||||||
var flat = new List<TaskRowViewModel>();
|
var flat = new List<TaskRowViewModel>();
|
||||||
|
var emitted = new HashSet<string>();
|
||||||
foreach (var parent in topLevel)
|
foreach (var parent in topLevel)
|
||||||
{
|
{
|
||||||
|
if (!emitted.Add(parent.Id)) continue;
|
||||||
flat.Add(parent);
|
flat.Add(parent);
|
||||||
// Also expand for Done parents so their (Done) children reach the classification
|
// Also expand for Done parents so their (Done) children reach the classification
|
||||||
// loop and land in CompletedItems alongside the parent.
|
// loop and land in CompletedItems alongside the parent.
|
||||||
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
|
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
|
||||||
{
|
{
|
||||||
var children = Items.Where(r => r.ParentTaskId == parent.Id);
|
var children = Items.Where(r => r.ParentTaskId == parent.Id);
|
||||||
flat.AddRange(children);
|
foreach (var c in children)
|
||||||
|
if (emitted.Add(c.Id))
|
||||||
|
flat.Add(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,37 +468,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
catch { /* offline; broadcast won't fire */ }
|
catch { /* offline; broadcast won't fire */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ToggleTagOnRowAsync(TaskRowViewModel row, string tagName)
|
|
||||||
{
|
|
||||||
if (_worker is null) return;
|
|
||||||
var name = tagName.Trim().ToLowerInvariant();
|
|
||||||
if (name.Length == 0) return;
|
|
||||||
var current = row.Tags.ToList();
|
|
||||||
var next = current.Contains(name)
|
|
||||||
? current.Where(t => t != name).ToList()
|
|
||||||
: current.Append(name).ToList();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _worker.SetTaskTagsAsync(row.Id, next);
|
|
||||||
await RefreshAllTagsAsync();
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null || row.IsRunning) return;
|
if (row is null || row.IsRunning) return;
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
var entity = await db.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == row.Id);
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||||
if (entity is null) return;
|
if (entity is null) return;
|
||||||
entity.Status = TaskStatus.Queued;
|
entity.Status = TaskStatus.Queued;
|
||||||
// Worker queue picker requires the "agent" tag — attach it on explicit enqueue.
|
|
||||||
if (!entity.Tags.Any(t => t.Name == "agent"))
|
|
||||||
{
|
|
||||||
var agentTag = await db.Tags.FirstOrDefaultAsync(t => t.Name == "agent");
|
|
||||||
if (agentTag is not null) entity.Tags.Add(agentTag);
|
|
||||||
}
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
row.Status = TaskStatus.Queued;
|
row.Status = TaskStatus.Queued;
|
||||||
if (_worker is not null)
|
if (_worker is not null)
|
||||||
@@ -569,6 +529,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CancelRunningTaskAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || !row.IsRunning || _worker is null) return;
|
||||||
|
try { await _worker.CancelTaskAsync(row.Id); }
|
||||||
|
catch { /* worker offline; the broadcast will reconcile when it returns */ }
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
|
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
|
||||||
{
|
{
|
||||||
if (row is null) return;
|
if (row is null) return;
|
||||||
@@ -650,7 +618,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
await _worker.FinalizePlanningSessionAsync(row.Id);
|
await _worker.FinalizePlanningSessionAsync(row.Id);
|
||||||
break;
|
break;
|
||||||
case UnfinishedPlanningModalResult.Discard:
|
case UnfinishedPlanningModalResult.Discard:
|
||||||
await _worker.DiscardPlanningSessionAsync(row.Id);
|
await TryDiscardPlanningWithRetryAsync(row.Id);
|
||||||
break;
|
break;
|
||||||
case UnfinishedPlanningModalResult.Cancel:
|
case UnfinishedPlanningModalResult.Cancel:
|
||||||
default:
|
default:
|
||||||
@@ -663,11 +631,46 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
|
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null) return;
|
if (row is null || _worker is null) return;
|
||||||
try { await _worker!.DiscardPlanningSessionAsync(row.Id); }
|
await TryDiscardPlanningWithRetryAsync(row.Id);
|
||||||
catch { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calls discard, and if it is blocked because children are queued, prompts the
|
||||||
|
/// user to dequeue them and retries. Running children are surfaced as a hard
|
||||||
|
/// block — the user must cancel them first.
|
||||||
|
/// </summary>
|
||||||
|
private async Task TryDiscardPlanningWithRetryAsync(string taskId)
|
||||||
|
{
|
||||||
|
if (_worker is null) return;
|
||||||
|
DiscardPlanningOutcome outcome;
|
||||||
|
try { outcome = await _worker.DiscardPlanningSessionAsync(taskId); }
|
||||||
|
catch { return; }
|
||||||
|
|
||||||
|
if (outcome.Result == DiscardPlanningResult.BlockedByQueuedChildren)
|
||||||
|
{
|
||||||
|
if (ConfirmAsync is null) return;
|
||||||
|
var ok = await ConfirmAsync(
|
||||||
|
$"{outcome.QueuedChildrenCount} child task(s) are queued.\n" +
|
||||||
|
"Dequeue them and discard the planning session?");
|
||||||
|
if (!ok) return;
|
||||||
|
try { await _worker.DiscardPlanningSessionAsync(taskId, dequeueQueuedChildren: true); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
else if (outcome.Result == DiscardPlanningResult.BlockedByRunningChildren)
|
||||||
|
{
|
||||||
|
if (ConfirmAsync is null) return;
|
||||||
|
await ConfirmAsync(
|
||||||
|
$"{outcome.RunningChildrenCount} child task(s) are still running.\n" +
|
||||||
|
"Cancel them first, then try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wired by the view via <see cref="ShowConfirmAsync"/>. Returns true when the user confirms.
|
||||||
|
/// </summary>
|
||||||
|
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
private readonly UpdateCheckService _updateCheck;
|
private readonly UpdateCheckService _updateCheck;
|
||||||
private readonly InstallerLocator _installerLocator;
|
private readonly InstallerLocator _installerLocator;
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
||||||
|
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
||||||
|
|
||||||
// Set by MainWindow to open the conflict resolution dialog.
|
// Set by MainWindow to open the conflict resolution dialog.
|
||||||
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
|
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
|
||||||
@@ -39,6 +40,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
// Set by MainWindow to open the About dialog.
|
// Set by MainWindow to open the About dialog.
|
||||||
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
|
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
|
||||||
|
|
||||||
|
// Set by MainWindow to open the global worktrees overview dialog.
|
||||||
|
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
||||||
|
|
||||||
[ObservableProperty] private bool _isUpdateBannerVisible;
|
[ObservableProperty] private bool _isUpdateBannerVisible;
|
||||||
[ObservableProperty] private string? _updateBannerLatestVersion;
|
[ObservableProperty] private string? _updateBannerLatestVersion;
|
||||||
[ObservableProperty] private string? _inlineUpdateStatus;
|
[ObservableProperty] private string? _inlineUpdateStatus;
|
||||||
@@ -159,12 +163,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
WorkerClient worker,
|
WorkerClient worker,
|
||||||
UpdateCheckService updateCheck,
|
UpdateCheckService updateCheck,
|
||||||
InstallerLocator installerLocator,
|
InstallerLocator installerLocator,
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory)
|
||||||
{
|
{
|
||||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||||
_updateCheck = updateCheck;
|
_updateCheck = updateCheck;
|
||||||
_installerLocator = installerLocator;
|
_installerLocator = installerLocator;
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
|
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
|
||||||
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
||||||
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
||||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||||
@@ -249,12 +255,67 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
if (ShowAboutModal is not null) await ShowAboutModal(vm);
|
if (ShowAboutModal is not null) await ShowAboutModal(vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenWorktreesOverviewGlobalAsync()
|
||||||
|
{
|
||||||
|
if (ShowWorktreesOverviewModal is null) return;
|
||||||
|
var vm = _worktreesOverviewVmFactory();
|
||||||
|
vm.Configure(null, null);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
await ShowWorktreesOverviewModal(vm);
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task CheckForUpdatesAsync()
|
private async Task CheckForUpdatesAsync()
|
||||||
{
|
{
|
||||||
await _updateCheck.CheckNowAsync(CancellationToken.None);
|
await _updateCheck.CheckNowAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ObservableProperty] private string? _restartWorkerStatus;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RestartWorkerAsync()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
await FlashRestartStatusAsync("Service control is Windows-only.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RestartWorkerStatus = "Restarting worker…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
using var sc = new System.ServiceProcess.ServiceController("ClaudeDoWorker");
|
||||||
|
if (sc.Status != System.ServiceProcess.ServiceControllerStatus.Stopped)
|
||||||
|
{
|
||||||
|
sc.Stop();
|
||||||
|
sc.WaitForStatus(System.ServiceProcess.ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(20));
|
||||||
|
}
|
||||||
|
sc.Start();
|
||||||
|
sc.WaitForStatus(System.ServiceProcess.ServiceControllerStatus.Running, TimeSpan.FromSeconds(20));
|
||||||
|
});
|
||||||
|
await FlashRestartStatusAsync("Worker restarted.");
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// ServiceController throws this when the service is not installed.
|
||||||
|
await FlashRestartStatusAsync("ClaudeDoWorker service is not installed.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await FlashRestartStatusAsync($"Restart failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FlashRestartStatusAsync(string text)
|
||||||
|
{
|
||||||
|
RestartWorkerStatus = text;
|
||||||
|
await Task.Delay(3000);
|
||||||
|
if (RestartWorkerStatus == text) RestartWorkerStatus = null;
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void DismissBanner()
|
private void DismissBanner()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,21 +14,16 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
[ObservableProperty] private string _name = "";
|
[ObservableProperty] private string _name = "";
|
||||||
[ObservableProperty] private string _workingDir = "";
|
[ObservableProperty] private string _workingDir = "";
|
||||||
[ObservableProperty] private string _defaultCommitType = "chore";
|
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||||
|
|
||||||
[ObservableProperty] private string _selectedModel = "(default)";
|
[ObservableProperty] private string _selectedModel = ModelRegistry.ListDefaultSentinel;
|
||||||
[ObservableProperty] private string _systemPrompt = "";
|
[ObservableProperty] private string _systemPrompt = "";
|
||||||
[ObservableProperty] private AgentInfo? _selectedAgent;
|
[ObservableProperty] private AgentInfo? _selectedAgent;
|
||||||
|
|
||||||
public ObservableCollection<string> ModelOptions { get; } = new()
|
public ObservableCollection<string> ModelOptions { get; } = new(
|
||||||
{
|
new[] { ModelRegistry.ListDefaultSentinel }.Concat(ModelRegistry.Aliases));
|
||||||
"(default)", "sonnet", "opus", "haiku",
|
|
||||||
};
|
|
||||||
|
|
||||||
public ObservableCollection<string> CommitTypeOptions { get; } = new()
|
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
||||||
{
|
|
||||||
"chore", "feat", "fix", "refactor", "docs", "test", "ci", "perf", "style", "build",
|
|
||||||
};
|
|
||||||
|
|
||||||
public ObservableCollection<AgentInfo> Agents { get; } = new();
|
public ObservableCollection<AgentInfo> Agents { get; } = new();
|
||||||
|
|
||||||
@@ -49,7 +44,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
ListId = listId;
|
ListId = listId;
|
||||||
Name = name;
|
Name = name;
|
||||||
WorkingDir = workingDir ?? "";
|
WorkingDir = workingDir ?? "";
|
||||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? "chore" : defaultCommitType;
|
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||||
|
|
||||||
Agents.Clear();
|
Agents.Clear();
|
||||||
Agents.Add(new AgentInfo("(none)", "", ""));
|
Agents.Add(new AgentInfo("(none)", "", ""));
|
||||||
@@ -57,7 +52,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
foreach (var a in agents) Agents.Add(a);
|
foreach (var a in agents) Agents.Add(a);
|
||||||
|
|
||||||
var config = await _worker.GetListConfigAsync(listId);
|
var config = await _worker.GetListConfigAsync(listId);
|
||||||
SelectedModel = string.IsNullOrWhiteSpace(config?.Model) ? "(default)" : config!.Model!;
|
SelectedModel = string.IsNullOrWhiteSpace(config?.Model) ? ModelRegistry.ListDefaultSentinel : config!.Model!;
|
||||||
SystemPrompt = config?.SystemPrompt ?? "";
|
SystemPrompt = config?.SystemPrompt ?? "";
|
||||||
SelectedAgent = string.IsNullOrWhiteSpace(config?.AgentPath)
|
SelectedAgent = string.IsNullOrWhiteSpace(config?.AgentPath)
|
||||||
? Agents[0]
|
? Agents[0]
|
||||||
@@ -67,7 +62,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SaveAsync()
|
private async Task SaveAsync()
|
||||||
{
|
{
|
||||||
var model = SelectedModel == "(default)" ? null : SelectedModel;
|
var model = SelectedModel == ModelRegistry.ListDefaultSentinel ? null : SelectedModel;
|
||||||
var sp = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt;
|
var sp = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt;
|
||||||
var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path;
|
var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path;
|
||||||
|
|
||||||
@@ -89,7 +84,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ResetAgentSettings()
|
private void ResetAgentSettings()
|
||||||
{
|
{
|
||||||
SelectedModel = "(default)";
|
SelectedModel = ModelRegistry.ListDefaultSentinel;
|
||||||
SystemPrompt = "";
|
SystemPrompt = "";
|
||||||
SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
|
SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||||
@@ -5,13 +6,12 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
|||||||
public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
[ObservableProperty] private string _defaultClaudeInstructions = "";
|
[ObservableProperty] private string _defaultClaudeInstructions = "";
|
||||||
[ObservableProperty] private string _defaultModel = "sonnet";
|
[ObservableProperty] private string _defaultModel = ModelRegistry.DefaultAlias;
|
||||||
[ObservableProperty] private int _defaultMaxTurns = 100;
|
[ObservableProperty] private int _defaultMaxTurns = 100;
|
||||||
[ObservableProperty] private string _defaultPermissionMode = "auto";
|
[ObservableProperty] private string _defaultPermissionMode = PermissionModeRegistry.DefaultMode;
|
||||||
|
|
||||||
public IReadOnlyList<string> Models { get; } = new[] { "opus", "sonnet", "haiku" };
|
public IReadOnlyList<string> Models { get; } = ModelRegistry.Aliases;
|
||||||
public IReadOnlyList<string> PermissionModes { get; } = new[]
|
public IReadOnlyList<string> PermissionModes { get; } = PermissionModeRegistry.Modes;
|
||||||
{ "auto", "bypassPermissions", "acceptEdits", "plan", "default" };
|
|
||||||
|
|
||||||
public string? Validate()
|
public string? Validate()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,12 +5,22 @@ using ClaudeDo.Data.Git;
|
|||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }
|
||||||
|
|
||||||
|
public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public required string Text { get; init; }
|
||||||
|
public required WorktreeDiffLineKind Kind { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed partial class WorktreeNodeViewModel : ViewModelBase
|
public sealed partial class WorktreeNodeViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
public string? Status { get; init; }
|
public string? Status { get; init; }
|
||||||
public bool IsDirectory { get; init; }
|
public bool IsDirectory { get; init; }
|
||||||
|
public string RelativePath { get; init; } = "";
|
||||||
public ObservableCollection<WorktreeNodeViewModel> Children { get; } = new();
|
public ObservableCollection<WorktreeNodeViewModel> Children { get; } = new();
|
||||||
|
[ObservableProperty] private bool _isExpanded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class WorktreeModalViewModel : ViewModelBase
|
public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||||
@@ -18,8 +28,11 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
|||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
|
|
||||||
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
|
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
|
||||||
|
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||||
|
|
||||||
[ObservableProperty] private string _worktreePath = "";
|
[ObservableProperty] private string _worktreePath = "";
|
||||||
|
[ObservableProperty] private string? _baseCommit;
|
||||||
|
[ObservableProperty] private WorktreeNodeViewModel? _selectedNode;
|
||||||
|
|
||||||
// Set by the view (same pattern as DiffModalViewModel.CloseAction)
|
// Set by the view (same pattern as DiffModalViewModel.CloseAction)
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
@@ -29,6 +42,43 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
|||||||
_git = git;
|
_git = git;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedNodeChanged(WorktreeNodeViewModel? value)
|
||||||
|
{
|
||||||
|
_ = LoadFileDiffAsync(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node)
|
||||||
|
{
|
||||||
|
SelectedFileDiffLines.Clear();
|
||||||
|
|
||||||
|
if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
string diff;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in diff.Split('\n'))
|
||||||
|
{
|
||||||
|
var kind = line switch
|
||||||
|
{
|
||||||
|
_ when line.StartsWith("+++") || line.StartsWith("---") => WorktreeDiffLineKind.Header,
|
||||||
|
_ when line.StartsWith("@@") => WorktreeDiffLineKind.Hunk,
|
||||||
|
_ when line.StartsWith('+') => WorktreeDiffLineKind.Added,
|
||||||
|
_ when line.StartsWith('-') => WorktreeDiffLineKind.Removed,
|
||||||
|
_ when line.StartsWith("diff ") || line.StartsWith("index ") || line.StartsWith("\\ ") => WorktreeDiffLineKind.Header,
|
||||||
|
_ => WorktreeDiffLineKind.Context,
|
||||||
|
};
|
||||||
|
SelectedFileDiffLines.Add(new WorktreeDiffLineViewModel { Text = line, Kind = kind });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Close() => CloseAction?.Invoke();
|
private void Close() => CloseAction?.Invoke();
|
||||||
|
|
||||||
@@ -37,7 +87,13 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
|||||||
Root.Clear();
|
Root.Clear();
|
||||||
|
|
||||||
string stdout;
|
string stdout;
|
||||||
try { stdout = await _git.GetStatusPorcelainAsync(WorktreePath, ct); }
|
bool committedMode = !string.IsNullOrEmpty(BaseCommit);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stdout = committedMode
|
||||||
|
? await _git.GetCommittedFilesAsync(WorktreePath, BaseCommit!, ct)
|
||||||
|
: await _git.GetStatusPorcelainAsync(WorktreePath, ct);
|
||||||
|
}
|
||||||
catch { return; }
|
catch { return; }
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(stdout)) return;
|
if (string.IsNullOrWhiteSpace(stdout)) return;
|
||||||
@@ -46,14 +102,27 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||||
{
|
{
|
||||||
if (line.Length < 4) continue;
|
string? path;
|
||||||
|
string? status;
|
||||||
|
|
||||||
// porcelain format: XY<space>path (XY = two-char status)
|
if (committedMode)
|
||||||
var xy = line[..2];
|
{
|
||||||
// Pick staged char first, fall back to unstaged
|
// diff --name-status format: <status>\t<path>
|
||||||
var statusChar = xy[0] != ' ' ? xy[0] : xy[1];
|
var tab = line.IndexOf('\t');
|
||||||
var status = statusChar != ' ' ? statusChar.ToString() : null;
|
if (tab < 0) continue;
|
||||||
var path = line[3..].Trim().Replace('\\', '/');
|
var statusChar = line[0];
|
||||||
|
status = statusChar != ' ' ? statusChar.ToString() : null;
|
||||||
|
path = line[(tab + 1)..].Trim().Replace('\\', '/');
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// porcelain format: XY<space>path
|
||||||
|
if (line.Length < 4) continue;
|
||||||
|
var xy = line[..2];
|
||||||
|
var statusChar = xy[0] != ' ' ? xy[0] : xy[1];
|
||||||
|
status = statusChar != ' ' ? statusChar.ToString() : null;
|
||||||
|
path = line[3..].Trim().Replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (segments.Length == 0) continue;
|
if (segments.Length == 0) continue;
|
||||||
@@ -77,10 +146,24 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
Name = segments[^1],
|
Name = segments[^1],
|
||||||
Status = status,
|
Status = status,
|
||||||
IsDirectory = false
|
IsDirectory = false,
|
||||||
|
RelativePath = path
|
||||||
};
|
};
|
||||||
if (parent == null) Root.Add(leaf);
|
if (parent == null) Root.Add(leaf);
|
||||||
else parent.Children.Add(leaf);
|
else parent.Children.Add(leaf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SelectedNode = FindFirstLeaf(Root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorktreeNodeViewModel? FindFirstLeaf(IEnumerable<WorktreeNodeViewModel> nodes)
|
||||||
|
{
|
||||||
|
foreach (var n in nodes)
|
||||||
|
{
|
||||||
|
if (!n.IsDirectory) return n;
|
||||||
|
var nested = FindFirstLeaf(n.Children);
|
||||||
|
if (nested is not null) return nested;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Input.Platform;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
[ObservableProperty] private string _taskId = "";
|
||||||
|
[ObservableProperty] private string _taskTitle = "";
|
||||||
|
[ObservableProperty] private TaskStatus _taskStatus;
|
||||||
|
[ObservableProperty] private string _listId = "";
|
||||||
|
[ObservableProperty] private string _listName = "";
|
||||||
|
[ObservableProperty] private string _path = "";
|
||||||
|
[ObservableProperty] private string _branchName = "";
|
||||||
|
[ObservableProperty] private string _baseCommit = "";
|
||||||
|
[ObservableProperty] private WorktreeState _state;
|
||||||
|
[ObservableProperty] private string? _diffStat;
|
||||||
|
[ObservableProperty] private DateTime _createdAt;
|
||||||
|
[ObservableProperty] private bool _pathExistsOnDisk;
|
||||||
|
[ObservableProperty] private bool _isSelected;
|
||||||
|
|
||||||
|
public string AgeText => FormatAge(DateTime.UtcNow - CreatedAt);
|
||||||
|
public bool IsActive => State == WorktreeState.Active;
|
||||||
|
public bool IsRunning => TaskStatus == TaskStatus.Running;
|
||||||
|
|
||||||
|
private static string FormatAge(TimeSpan ts)
|
||||||
|
{
|
||||||
|
if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d ago";
|
||||||
|
if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h ago";
|
||||||
|
if (ts.TotalMinutes >= 1) return $"{(int)ts.TotalMinutes}m ago";
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class WorktreesGroupViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public required string ListId { get; init; }
|
||||||
|
public required string ListName { get; init; }
|
||||||
|
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly WorkerClient _worker;
|
||||||
|
private readonly Func<WorktreeModalViewModel> _diffVmFactory;
|
||||||
|
|
||||||
|
[ObservableProperty] private string? _listIdFilter;
|
||||||
|
[ObservableProperty] private string _title = "Worktrees";
|
||||||
|
[ObservableProperty] private bool _isGlobal;
|
||||||
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
[ObservableProperty] private string? _statusMessage;
|
||||||
|
[ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow;
|
||||||
|
|
||||||
|
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
|
||||||
|
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
|
||||||
|
|
||||||
|
public Action? CloseAction { get; set; }
|
||||||
|
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
|
||||||
|
public Action<string, string>? JumpToTaskAction { get; set; }
|
||||||
|
public Func<string, Task<bool>>? ConfirmAction { get; set; }
|
||||||
|
|
||||||
|
public WorktreesOverviewModalViewModel(WorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
|
||||||
|
{
|
||||||
|
_worker = worker;
|
||||||
|
_diffVmFactory = diffVmFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SelectRow(WorktreeOverviewRowViewModel row)
|
||||||
|
{
|
||||||
|
if (SelectedRow is not null) SelectedRow.IsSelected = false;
|
||||||
|
SelectedRow = row;
|
||||||
|
row.IsSelected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Configure(string? listId, string? listName)
|
||||||
|
{
|
||||||
|
ListIdFilter = listId;
|
||||||
|
IsGlobal = listId is null;
|
||||||
|
Title = listId is null ? "Worktrees" : $"Worktrees — {listName ?? "list"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dtos = await _worker.GetWorktreesOverviewAsync(ListIdFilter);
|
||||||
|
var ordered = dtos
|
||||||
|
.OrderBy(d => d.State == WorktreeState.Active ? 0 : 1)
|
||||||
|
.ThenByDescending(d => d.CreatedAt)
|
||||||
|
.Select(Map)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Rows.Clear();
|
||||||
|
Groups.Clear();
|
||||||
|
if (IsGlobal)
|
||||||
|
{
|
||||||
|
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName)).OrderBy(g => g.Key.ListName))
|
||||||
|
{
|
||||||
|
var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
|
||||||
|
foreach (var row in grp) group.Rows.Add(row);
|
||||||
|
Groups.Add(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var row in ordered) Rows.Add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task Refresh()
|
||||||
|
{
|
||||||
|
StatusMessage = null;
|
||||||
|
return LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CleanupFinished()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _worker.CleanupFinishedWorktreesAsync(ListIdFilter);
|
||||||
|
StatusMessage = result is null ? "Cleanup failed." : $"Removed {result.Removed} worktree(s).";
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Close() => CloseAction?.Invoke();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ShowDiff(WorktreeOverviewRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
var diffVm = _diffVmFactory();
|
||||||
|
diffVm.WorktreePath = row.Path;
|
||||||
|
diffVm.BaseCommit = string.IsNullOrEmpty(row.BaseCommit) ? null : row.BaseCommit;
|
||||||
|
ShowDiffAction?.Invoke(diffVm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenInExplorer(WorktreeOverviewRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || !row.PathExistsOnDisk) return;
|
||||||
|
try { Process.Start(new ProcessStartInfo { FileName = "explorer.exe", Arguments = $"\"{row.Path}\"", UseShellExecute = true }); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void JumpToTask(WorktreeOverviewRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
JumpToTaskAction?.Invoke(row.ListId, row.TaskId);
|
||||||
|
CloseAction?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task Discard(WorktreeOverviewRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || row.State != WorktreeState.Active) return;
|
||||||
|
if (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded))
|
||||||
|
row.State = WorktreeState.Discarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task Keep(WorktreeOverviewRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || row.State != WorktreeState.Active) return;
|
||||||
|
if (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Kept))
|
||||||
|
row.State = WorktreeState.Kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ForceRemove(WorktreeOverviewRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
if (row.IsRunning) { StatusMessage = "Cannot force-remove a running task."; return; }
|
||||||
|
if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return;
|
||||||
|
|
||||||
|
var result = await _worker.ForceRemoveWorktreeAsync(row.TaskId);
|
||||||
|
if (result is null || !result.Removed)
|
||||||
|
{
|
||||||
|
StatusMessage = result?.Reason ?? "Force remove failed.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (IsGlobal)
|
||||||
|
{
|
||||||
|
foreach (var grp in Groups)
|
||||||
|
{
|
||||||
|
var idx = grp.Rows.IndexOf(row);
|
||||||
|
if (idx >= 0) { grp.Rows.RemoveAt(idx); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Rows.Remove(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task CopyBranch(WorktreeOverviewRowViewModel? row) => CopyToClipboardAsync(row?.BranchName);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task CopyPath(WorktreeOverviewRowViewModel? row) => CopyToClipboardAsync(row?.Path);
|
||||||
|
|
||||||
|
private static async Task CopyToClipboardAsync(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return;
|
||||||
|
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop &&
|
||||||
|
desktop.MainWindow?.Clipboard is { } clipboard)
|
||||||
|
{
|
||||||
|
try { await clipboard.SetTextAsync(text); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorktreeOverviewRowViewModel Map(WorktreeOverviewDto d) => new()
|
||||||
|
{
|
||||||
|
TaskId = d.TaskId, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus,
|
||||||
|
ListId = d.ListId, ListName = d.ListName,
|
||||||
|
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
|
||||||
|
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -42,13 +42,22 @@
|
|||||||
<PathIcon Data="{StaticResource Icon.X}" Width="12" Height="12"
|
<PathIcon Data="{StaticResource Icon.X}" Width="12" Height="12"
|
||||||
Foreground="{DynamicResource BloodBrush}"/>
|
Foreground="{DynamicResource BloodBrush}"/>
|
||||||
</Button>
|
</Button>
|
||||||
<!-- Hand off button — only when idle -->
|
<!-- Send to queue — only when idle -->
|
||||||
<Button Grid.Column="3"
|
<Button Grid.Column="3"
|
||||||
Classes="btn accent"
|
Classes="btn accent"
|
||||||
Content="Hand off"
|
Content="Send to queue"
|
||||||
Command="{Binding RunNowCommand}"
|
Command="{Binding EnqueueCommand}"
|
||||||
IsVisible="{Binding !IsRunning}"
|
IsVisible="{Binding IsIdle}"
|
||||||
ToolTip.Tip="Hand task off to Claude"
|
ToolTip.Tip="Queue this task for the worker to pick up"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Padding="10,4"/>
|
||||||
|
<!-- Remove from queue — only when queued -->
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Classes="btn"
|
||||||
|
Content="Remove from queue"
|
||||||
|
Command="{Binding DequeueCommand}"
|
||||||
|
IsVisible="{Binding IsQueued}"
|
||||||
|
ToolTip.Tip="Take this task back out of the queue"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Padding="10,4"/>
|
Padding="10,4"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -144,14 +153,14 @@
|
|||||||
<Button Classes="btn accent"
|
<Button Classes="btn accent"
|
||||||
Content="Continue"
|
Content="Continue"
|
||||||
Command="{Binding ContinueCommand}"
|
Command="{Binding ContinueCommand}"
|
||||||
IsVisible="{Binding ShowFailedActions}"
|
IsVisible="{Binding ShowContinue}"
|
||||||
ToolTip.Tip="Resume the task from where it failed"
|
ToolTip.Tip="Resume the last session and keep going"
|
||||||
Padding="10,4"/>
|
Padding="10,4"/>
|
||||||
<Button Classes="btn"
|
<Button Classes="btn"
|
||||||
Content="Reset"
|
Content="Reset & retry"
|
||||||
Command="{Binding ResetCommand}"
|
Command="{Binding ResetAndRetryCommand}"
|
||||||
IsVisible="{Binding ShowFailedActions}"
|
IsVisible="{Binding ShowResetAndRetry}"
|
||||||
ToolTip.Tip="Discard the worktree and move the task back to Manual"
|
ToolTip.Tip="Discard the worktree and re-queue the task to run from scratch"
|
||||||
Padding="10,4"/>
|
Padding="10,4"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -35,36 +35,39 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ── Header (sticky top): eyebrow · title · gear (agent-settings flyout) ── -->
|
<!-- ── Header (sticky top): check · eyebrow · title · status · star · gear ── -->
|
||||||
<Border DockPanel.Dock="Top" Classes="island-header">
|
<Border DockPanel.Dock="Top" Classes="island-header">
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
<StackPanel Grid.Column="0" Spacing="0">
|
<Ellipse Grid.Column="0"
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4">
|
Classes="task-check"
|
||||||
<Ellipse Width="5" Height="5" Fill="{DynamicResource AccentBrush}"
|
Classes.done="{Binding Task.Done}"
|
||||||
VerticalAlignment="Center"/>
|
Width="18" Height="18"
|
||||||
<TextBlock Classes="eyebrow" Text="LOGBOOK" VerticalAlignment="Center"/>
|
VerticalAlignment="Top"
|
||||||
<TextBlock Text="{Binding TaskIdBadge}"
|
Margin="0,2,10,0"
|
||||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
Cursor="Hand"/>
|
||||||
Foreground="{DynamicResource TextFaintBrush}"
|
<StackPanel Grid.Column="1" Spacing="0">
|
||||||
VerticalAlignment="Center"
|
<TextBlock Text="{Binding TaskIdBadge}"
|
||||||
Margin="8,0,0,0"/>
|
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||||
</StackPanel>
|
Foreground="{DynamicResource TextFaintBrush}"
|
||||||
|
Margin="0,0,0,4"/>
|
||||||
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
|
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
|
||||||
FontSize="14" FontWeight="Medium"
|
FontSize="14" FontWeight="Medium"
|
||||||
BorderThickness="0" Background="Transparent"
|
BorderThickness="0" Background="Transparent"
|
||||||
Foreground="{DynamicResource TextBrush}"
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
AcceptsReturn="False"
|
||||||
Padding="0"/>
|
Padding="0"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<ComboBox Grid.Column="1"
|
<Button Grid.Column="2"
|
||||||
ItemsSource="{Binding StatusOptions}"
|
Classes="icon-btn star-btn"
|
||||||
SelectedItem="{Binding SelectedStatus, Mode=TwoWay}"
|
Classes.on="{Binding Task.IsStarred}"
|
||||||
ToolTip.Tip="Set status (no transition guards)"
|
VerticalAlignment="Top"
|
||||||
VerticalAlignment="Top"
|
Margin="6,0,0,0">
|
||||||
MinWidth="110"
|
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
|
||||||
Margin="6,0,0,0"/>
|
</Button>
|
||||||
|
|
||||||
<Button Grid.Column="2" Classes="icon-btn"
|
<Button Grid.Column="3" Classes="icon-btn"
|
||||||
ToolTip.Tip="Agent settings"
|
ToolTip.Tip="Agent settings"
|
||||||
IsEnabled="{Binding IsAgentSectionEnabled}"
|
IsEnabled="{Binding IsAgentSectionEnabled}"
|
||||||
VerticalAlignment="Top"
|
VerticalAlignment="Top"
|
||||||
@@ -112,34 +115,6 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ── Task strip row (sticky top): check + title + star ── -->
|
|
||||||
<Border DockPanel.Dock="Top"
|
|
||||||
Padding="18,10,18,10"
|
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
|
||||||
BorderThickness="0,0,0,1">
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
|
||||||
<Ellipse Grid.Column="0"
|
|
||||||
Classes="task-check"
|
|
||||||
Classes.done="{Binding Task.Done}"
|
|
||||||
Width="18" Height="18"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Cursor="Hand"/>
|
|
||||||
<TextBlock Grid.Column="1"
|
|
||||||
Text="{Binding EditableTitle}"
|
|
||||||
FontSize="14" FontWeight="Medium"
|
|
||||||
Foreground="{DynamicResource TextBrush}"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Margin="10,0"/>
|
|
||||||
<Button Grid.Column="2"
|
|
||||||
Classes="icon-btn star-btn"
|
|
||||||
Classes.on="{Binding Task.IsStarred}"
|
|
||||||
VerticalAlignment="Center">
|
|
||||||
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- ── Agent status strip (sticky, above metadata footer) ── -->
|
<!-- ── Agent status strip (sticky, above metadata footer) ── -->
|
||||||
<islands:AgentStripView DockPanel.Dock="Bottom"/>
|
<islands:AgentStripView DockPanel.Dock="Bottom"/>
|
||||||
|
|
||||||
@@ -147,46 +122,6 @@
|
|||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Spacing="0">
|
<StackPanel Spacing="0">
|
||||||
|
|
||||||
<!-- Tags section -->
|
|
||||||
<Border Padding="18,12,18,12"
|
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
|
||||||
BorderThickness="0,0,0,1">
|
|
||||||
<StackPanel Spacing="6">
|
|
||||||
<TextBlock Classes="section-label" Text="TAGS" Margin="0,0,0,2"/>
|
|
||||||
<ItemsControl ItemsSource="{Binding Tags}">
|
|
||||||
<ItemsControl.ItemsPanel>
|
|
||||||
<ItemsPanelTemplate>
|
|
||||||
<WrapPanel Orientation="Horizontal"/>
|
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="x:String">
|
|
||||||
<Border Classes="chip chip-tag" Margin="0,0,6,4">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="{Binding}" VerticalAlignment="Center"/>
|
|
||||||
<Button Classes="icon-btn"
|
|
||||||
Padding="2,0"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
ToolTip.Tip="Remove tag"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).RemoveTagCommand}"
|
|
||||||
CommandParameter="{Binding}">
|
|
||||||
<TextBlock Text="×" FontSize="12"/>
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
<AutoCompleteBox ItemsSource="{Binding AvailableTags}"
|
|
||||||
Text="{Binding NewTagInput, Mode=TwoWay}"
|
|
||||||
Watermark="Add tag (Enter to add)">
|
|
||||||
<AutoCompleteBox.KeyBindings>
|
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}"/>
|
|
||||||
</AutoCompleteBox.KeyBindings>
|
|
||||||
</AutoCompleteBox>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Planning merge section — visible only for planning parent tasks -->
|
<!-- Planning merge section — visible only for planning parent tasks -->
|
||||||
<Border Padding="18,12,18,12"
|
<Border Padding="18,12,18,12"
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
|||||||
@@ -132,6 +132,9 @@
|
|||||||
<MenuItem Header="Settings..."
|
<MenuItem Header="Settings..."
|
||||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
|
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
|
||||||
CommandParameter="{Binding}"/>
|
CommandParameter="{Binding}"/>
|
||||||
|
<MenuItem Header="Worktrees…"
|
||||||
|
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Border.ContextMenu>
|
</Border.ContextMenu>
|
||||||
<Grid ColumnDefinitions="20,*,Auto">
|
<Grid ColumnDefinitions="20,*,Auto">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
@@ -25,6 +26,31 @@ public partial class ListsIslandView : UserControl
|
|||||||
if (top is null) window.Show();
|
if (top is null) window.Show();
|
||||||
else await window.ShowDialog(top);
|
else await window.ShowDialog(top);
|
||||||
};
|
};
|
||||||
|
vm.ShowWorktreesOverviewModal = async modal =>
|
||||||
|
{
|
||||||
|
var window = new WorktreesOverviewModalView { DataContext = modal };
|
||||||
|
modal.CloseAction = () => window.Close();
|
||||||
|
modal.JumpToTaskAction = (listId, _) =>
|
||||||
|
{
|
||||||
|
if (vm is { } v)
|
||||||
|
{
|
||||||
|
var item = v.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
|
||||||
|
if (item is not null) v.SelectedList = item;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
modal.ShowDiffAction = diffVm =>
|
||||||
|
{
|
||||||
|
var top2 = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (top2 is null) return;
|
||||||
|
var dlg = new WorktreeModalView { DataContext = diffVm };
|
||||||
|
diffVm.CloseAction = () => dlg.Close();
|
||||||
|
_ = diffVm.LoadAsync();
|
||||||
|
_ = dlg.ShowDialog(top2);
|
||||||
|
};
|
||||||
|
var top = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (top is null) window.Show();
|
||||||
|
else await window.ShowDialog(top);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,23 +31,21 @@
|
|||||||
Classes.selected="{Binding IsSelected}"
|
Classes.selected="{Binding IsSelected}"
|
||||||
Classes.done="{Binding Done}">
|
Classes.done="{Binding Done}">
|
||||||
<Border.ContextMenu>
|
<Border.ContextMenu>
|
||||||
<ContextMenu Opening="OnContextMenuOpening">
|
<ContextMenu>
|
||||||
<MenuItem Header="Send to queue"
|
<MenuItem Header="Send to queue"
|
||||||
IsVisible="{Binding !IsQueued}"
|
IsVisible="{Binding CanSendToQueue}"
|
||||||
Click="OnSendToQueueClick"/>
|
Click="OnSendToQueueClick"/>
|
||||||
<MenuItem Header="Remove from queue"
|
<MenuItem Header="Remove from queue"
|
||||||
IsVisible="{Binding CanRemoveFromQueue}"
|
IsVisible="{Binding CanRemoveFromQueue}"
|
||||||
Click="OnRemoveFromQueueClick"/>
|
Click="OnRemoveFromQueueClick"/>
|
||||||
|
<MenuItem Header="Cancel execution"
|
||||||
|
IsVisible="{Binding IsRunning}"
|
||||||
|
Click="OnCancelExecutionClick"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="Set status">
|
<MenuItem Header="Mark as">
|
||||||
<MenuItem Header="Idle" Tag="Idle" Click="OnSetStatusClick"/>
|
|
||||||
<MenuItem Header="Queued" Tag="Queued" Click="OnSetStatusClick"/>
|
|
||||||
<MenuItem Header="Running" Tag="Running" Click="OnSetStatusClick"/>
|
|
||||||
<MenuItem Header="Done" Tag="Done" Click="OnSetStatusClick"/>
|
<MenuItem Header="Done" Tag="Done" Click="OnSetStatusClick"/>
|
||||||
<MenuItem Header="Failed" Tag="Failed" Click="OnSetStatusClick"/>
|
|
||||||
<MenuItem Header="Cancelled" Tag="Cancelled" Click="OnSetStatusClick"/>
|
<MenuItem Header="Cancelled" Tag="Cancelled" Click="OnSetStatusClick"/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem Header="Tags" x:Name="TagsMenu"/>
|
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="Run interactively"
|
<MenuItem Header="Run interactively"
|
||||||
Click="OnRunInteractivelyClick"/>
|
Click="OnRunInteractivelyClick"/>
|
||||||
@@ -99,16 +97,19 @@
|
|||||||
|
|
||||||
<!-- Title + chip row + live tail -->
|
<!-- Title + chip row + live tail -->
|
||||||
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
|
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||||
<TextBlock Classes="task-title"
|
<TextBlock Grid.Column="0"
|
||||||
|
Classes="task-title"
|
||||||
Text="{Binding Title}" FontSize="14"
|
Text="{Binding Title}" FontSize="14"
|
||||||
Foreground="{DynamicResource TextBrush}"
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
|
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
|
||||||
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
|
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
|
||||||
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
|
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
|
||||||
|
|
||||||
<!-- Badges: DRAFT and planning session -->
|
<!-- Badges: DRAFT and planning session -->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
|
||||||
|
VerticalAlignment="Center" Margin="4,0,0,0">
|
||||||
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
||||||
<TextBlock Text="DRAFT"/>
|
<TextBlock Text="DRAFT"/>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
<TextBlock Text="{Binding PlanningBadge}"/>
|
<TextBlock Text="{Binding PlanningBadge}"/>
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</Grid>
|
||||||
|
|
||||||
<!-- Chip row -->
|
<!-- Chip row -->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
@@ -167,21 +168,6 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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) -->
|
<!-- Live-tail row (visible when running + has tail) -->
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ public partial class TaskRowView : UserControl
|
|||||||
await vm.RemoveFromQueueCommand.ExecuteAsync(row);
|
await vm.RemoveFromQueueCommand.ExecuteAsync(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnCancelExecutionClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.CancelRunningTaskCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnClearScheduleClick(object? sender, RoutedEventArgs e)
|
private async void OnClearScheduleClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
@@ -82,37 +88,6 @@ public partial class TaskRowView : UserControl
|
|||||||
await vm.SetStatusOnRowAsync(row, status);
|
await vm.SetStatusOnRowAsync(row, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is not TaskRowViewModel row || FindTasksVm() is not { } vm) return;
|
|
||||||
|
|
||||||
// Build the union of all known tags + tags currently on this row, so a row's
|
|
||||||
// own tags stay reachable from the menu even if the global list is stale.
|
|
||||||
var rowTags = row.Tags.ToHashSet();
|
|
||||||
var union = vm.AllTags.Concat(rowTags).Distinct().OrderBy(t => t).ToList();
|
|
||||||
|
|
||||||
TagsMenu.Items.Clear();
|
|
||||||
if (union.Count == 0)
|
|
||||||
{
|
|
||||||
TagsMenu.Items.Add(new MenuItem { Header = "(no tags yet)", IsEnabled = false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
foreach (var name in union)
|
|
||||||
{
|
|
||||||
var prefix = rowTags.Contains(name) ? "✓ " : " ";
|
|
||||||
var item = new MenuItem { Header = prefix + name, Tag = name };
|
|
||||||
item.Click += OnToggleTagClick;
|
|
||||||
TagsMenu.Items.Add(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnToggleTagClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (sender is not MenuItem mi || mi.Tag is not string name) return;
|
|
||||||
if (DataContext is not TaskRowViewModel row || FindTasksVm() is not { } vm) return;
|
|
||||||
await vm.ToggleTagOnRowAsync(row, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
|
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not TaskRowViewModel row) return;
|
if (DataContext is not TaskRowViewModel row) return;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using Avalonia;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
@@ -33,10 +35,55 @@ public partial class TasksIslandView : UserControl
|
|||||||
await modal.ShowDialog(owner);
|
await modal.ShowDialog(owner);
|
||||||
// ShowDialog completes once the window is closed (CloseAction or OS close).
|
// ShowDialog completes once the window is closed (CloseAction or OS close).
|
||||||
};
|
};
|
||||||
|
vm.ConfirmAsync = ShowConfirmAsync;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner is null) return false;
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource<bool>();
|
||||||
|
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||||
|
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
|
||||||
|
|
||||||
|
var dialog = new Window
|
||||||
|
{
|
||||||
|
Title = "Confirm",
|
||||||
|
Width = 380,
|
||||||
|
SizeToContent = SizeToContent.Height,
|
||||||
|
CanResize = false,
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||||
|
ShowInTaskbar = false,
|
||||||
|
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||||
|
Content = new StackPanel
|
||||||
|
{
|
||||||
|
Margin = new Thickness(20),
|
||||||
|
Spacing = 16,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||||
|
new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
Spacing = 8,
|
||||||
|
Children = { cancel, confirm },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
|
||||||
|
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
|
||||||
|
dialog.Closed += (_, _) => tcs.TrySetResult(false);
|
||||||
|
|
||||||
|
_ = dialog.ShowDialog(owner);
|
||||||
|
return await tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
|
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not TasksIslandViewModel vm) return;
|
if (DataContext is not TasksIslandViewModel vm) return;
|
||||||
|
|||||||
@@ -67,6 +67,10 @@
|
|||||||
Foreground="{DynamicResource TextDimBrush}">
|
Foreground="{DynamicResource TextDimBrush}">
|
||||||
<MenuItem Header="Check for updates"
|
<MenuItem Header="Check for updates"
|
||||||
Command="{Binding CheckForUpdatesCommand}"/>
|
Command="{Binding CheckForUpdatesCommand}"/>
|
||||||
|
<MenuItem Header="Restart worker"
|
||||||
|
Command="{Binding RestartWorkerCommand}"/>
|
||||||
|
<MenuItem Header="Worktrees…"
|
||||||
|
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
|
||||||
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
|
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
@@ -149,10 +153,10 @@
|
|||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ColumnDefinition Width="*" MinWidth="320"/>
|
<ColumnDefinition Width="*" MinWidth="320"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ColumnDefinition Width="320" MinWidth="280"/>
|
<ColumnDefinition Width="460" MinWidth="280"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<Border Grid.Column="0" Classes="island" Margin="7">
|
<Border Grid.Column="0" Classes="island" Margin="3">
|
||||||
<islands:ListsIslandView DataContext="{Binding Lists}"/>
|
<islands:ListsIslandView DataContext="{Binding Lists}"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -164,7 +168,7 @@
|
|||||||
ResizeDirection="Columns"
|
ResizeDirection="Columns"
|
||||||
ResizeBehavior="PreviousAndNext"/>
|
ResizeBehavior="PreviousAndNext"/>
|
||||||
|
|
||||||
<Border Grid.Column="2" Classes="island" Margin="7">
|
<Border Grid.Column="2" Classes="island" Margin="3">
|
||||||
<islands:TasksIslandView DataContext="{Binding Tasks}"/>
|
<islands:TasksIslandView DataContext="{Binding Tasks}"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -177,7 +181,7 @@
|
|||||||
ResizeBehavior="PreviousAndNext"
|
ResizeBehavior="PreviousAndNext"
|
||||||
IsVisible="{Binding ShowDetails}"/>
|
IsVisible="{Binding ShowDetails}"/>
|
||||||
|
|
||||||
<Border Grid.Column="4" Classes="island" Margin="7"
|
<Border Grid.Column="4" Classes="island" Margin="3"
|
||||||
IsVisible="{Binding ShowDetails}">
|
IsVisible="{Binding ShowDetails}">
|
||||||
<islands:DetailsIslandView DataContext="{Binding Details}"/>
|
<islands:DetailsIslandView DataContext="{Binding Details}"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
using System.Linq;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
using ClaudeDo.Ui.Views.Modals;
|
using ClaudeDo.Ui.Views.Modals;
|
||||||
using ClaudeDo.Ui.Views.Planning;
|
using ClaudeDo.Ui.Views.Planning;
|
||||||
|
|
||||||
@@ -31,6 +34,27 @@ public partial class MainWindow : Window
|
|||||||
aboutVm.CloseAction = () => { dlg.Close(); tcs.TrySetResult(true); };
|
aboutVm.CloseAction = () => { dlg.Close(); tcs.TrySetResult(true); };
|
||||||
await dlg.ShowDialog(this);
|
await dlg.ShowDialog(this);
|
||||||
};
|
};
|
||||||
|
vm.ShowWorktreesOverviewModal = async (modal) =>
|
||||||
|
{
|
||||||
|
var dlg = new WorktreesOverviewModalView { DataContext = modal };
|
||||||
|
modal.CloseAction = () => dlg.Close();
|
||||||
|
modal.JumpToTaskAction = (listId, _) =>
|
||||||
|
{
|
||||||
|
if (DataContext is IslandsShellViewModel s)
|
||||||
|
{
|
||||||
|
var item = s.Lists?.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
|
||||||
|
if (item is not null && s.Lists is not null) s.Lists.SelectedList = item;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
modal.ShowDiffAction = diffVm =>
|
||||||
|
{
|
||||||
|
var diffDlg = new WorktreeModalView { DataContext = diffVm };
|
||||||
|
diffVm.CloseAction = () => diffDlg.Close();
|
||||||
|
_ = diffVm.LoadAsync();
|
||||||
|
_ = diffDlg.ShowDialog(this);
|
||||||
|
};
|
||||||
|
await dlg.ShowDialog(this);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||||
|
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
||||||
x:Class="ClaudeDo.Ui.Views.Modals.WorktreeModalView"
|
x:Class="ClaudeDo.Ui.Views.Modals.WorktreeModalView"
|
||||||
x:DataType="vm:WorktreeModalViewModel"
|
x:DataType="vm:WorktreeModalViewModel"
|
||||||
Title="Worktree"
|
Title="Worktree"
|
||||||
Width="640" Height="720"
|
Width="1100" Height="720"
|
||||||
|
MinWidth="640" MinHeight="400"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
SystemDecorations="None"
|
SystemDecorations="BorderOnly"
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
ExtendClientAreaTitleBarHeightHint="-1"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
CanResize="False"
|
CanResize="True"
|
||||||
TransparencyLevelHint="AcrylicBlur">
|
TransparencyLevelHint="AcrylicBlur">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
<Window.KeyBindings>
|
<Window.KeyBindings>
|
||||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||||
</Window.KeyBindings>
|
</Window.KeyBindings>
|
||||||
@@ -39,27 +46,64 @@
|
|||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- File tree -->
|
<!-- Split: file tree | splitter | diff pane -->
|
||||||
<TreeView DockPanel.Dock="Top" ItemsSource="{Binding Root}"
|
<Grid ColumnDefinitions="260,4,*">
|
||||||
Background="Transparent" Margin="8,0,8,8">
|
|
||||||
<TreeView.ItemTemplate>
|
<!-- Left: file tree -->
|
||||||
<TreeDataTemplate DataType="vm:WorktreeNodeViewModel"
|
<TreeView x:Name="FileTree"
|
||||||
ItemsSource="{Binding Children}">
|
Grid.Column="0"
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
ItemsSource="{Binding Root}"
|
||||||
<TextBlock Text="{Binding Name}"
|
SelectedItem="{Binding SelectedNode, Mode=TwoWay}"
|
||||||
FontFamily="{DynamicResource MonoFont}" FontSize="12"
|
Background="Transparent"
|
||||||
Foreground="{DynamicResource TextBrush}"/>
|
Margin="8,0,4,8">
|
||||||
<Border Tag="{Binding Status}" CornerRadius="3" Padding="4,0"
|
<TreeView.Styles>
|
||||||
VerticalAlignment="Center"
|
<Style Selector="TreeViewItem" x:DataType="vm:WorktreeNodeViewModel">
|
||||||
IsVisible="{Binding Status, Converter={x:Static ObjectConverters.IsNotNull}}">
|
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
|
||||||
<TextBlock Text="{Binding Status}"
|
</Style>
|
||||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
</TreeView.Styles>
|
||||||
|
<TreeView.ItemTemplate>
|
||||||
|
<TreeDataTemplate DataType="vm:WorktreeNodeViewModel"
|
||||||
|
ItemsSource="{Binding Children}">
|
||||||
|
<Border Background="Transparent" Tapped="OnNodeTapped" Cursor="Hand">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text="{Binding Name}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="12"
|
||||||
Foreground="{DynamicResource TextBrush}"/>
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
<Border Tag="{Binding Status}" CornerRadius="3" Padding="4,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding Status, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<TextBlock Text="{Binding Status}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||||
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</TreeDataTemplate>
|
||||||
</TreeDataTemplate>
|
</TreeView.ItemTemplate>
|
||||||
</TreeView.ItemTemplate>
|
</TreeView>
|
||||||
</TreeView>
|
|
||||||
|
<!-- Splitter -->
|
||||||
|
<GridSplitter Grid.Column="1" ResizeDirection="Columns" Background="{DynamicResource LineBrush}"/>
|
||||||
|
|
||||||
|
<!-- Right: diff content -->
|
||||||
|
<ScrollViewer Grid.Column="2" Padding="8"
|
||||||
|
HorizontalScrollBarVisibility="Auto"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
Margin="4,0,8,8">
|
||||||
|
<ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:WorktreeDiffLineViewModel">
|
||||||
|
<SelectableTextBlock Text="{Binding Text}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
|
||||||
|
TextWrapping="NoWrap"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Modals;
|
namespace ClaudeDo.Ui.Views.Modals;
|
||||||
@@ -21,6 +22,18 @@ public partial class WorktreeModalView : Window
|
|||||||
base.OnDataContextChanged(e);
|
base.OnDataContextChanged(e);
|
||||||
if (DataContext is WorktreeModalViewModel vm)
|
if (DataContext is WorktreeModalViewModel vm)
|
||||||
vm.CloseAction = Close;
|
vm.CloseAction = Close;
|
||||||
|
|
||||||
|
// Wire TreeView selection — SelectedItem TwoWay binding may not fire
|
||||||
|
// reliably in Avalonia 12 for TreeView; use SelectionChanged as backup.
|
||||||
|
var tree = this.FindControl<TreeView>("FileTree");
|
||||||
|
if (tree is not null)
|
||||||
|
tree.SelectionChanged += OnFileTreeSelectionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFileTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is WorktreeModalViewModel vm && sender is TreeView tree)
|
||||||
|
vm.SelectedNode = tree.SelectedItem as WorktreeNodeViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async void OnOpened(EventArgs e)
|
protected override async void OnOpened(EventArgs e)
|
||||||
@@ -44,6 +57,15 @@ public partial class WorktreeModalView : Window
|
|||||||
RenderTransform = new ScaleTransform(1.0, 1.0);
|
RenderTransform = new ScaleTransform(1.0, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnNodeTapped(object? sender, Avalonia.Input.TappedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Control c) return;
|
||||||
|
if (c.DataContext is not WorktreeNodeViewModel node) return;
|
||||||
|
if (!node.IsDirectory) return;
|
||||||
|
node.IsExpanded = !node.IsExpanded;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
|
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
|||||||
209
src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml
Normal file
209
src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||||
|
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Modals.WorktreesOverviewModalView"
|
||||||
|
x:DataType="vm:WorktreesOverviewModalViewModel"
|
||||||
|
Title="{Binding Title}"
|
||||||
|
Width="900" Height="560" MinWidth="640" MinHeight="360"
|
||||||
|
CanResize="True"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource SurfaceBrush}"
|
||||||
|
SystemDecorations="BorderOnly"
|
||||||
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
ExtendClientAreaTitleBarHeightHint="-1">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<converters:WorktreeStateColorConverter x:Key="WorktreeStateColor"/>
|
||||||
|
<DataTemplate x:Key="WorktreeRowTemplate" x:DataType="vm:WorktreeOverviewRowViewModel">
|
||||||
|
<Border Classes="wt-row"
|
||||||
|
Classes.selected="{Binding IsSelected}"
|
||||||
|
Tapped="OnRowTapped">
|
||||||
|
<Border.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="Show diff"
|
||||||
|
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ShowDiffCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
<MenuItem Header="Open in Explorer"
|
||||||
|
IsEnabled="{Binding PathExistsOnDisk}"
|
||||||
|
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).OpenInExplorerCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
<MenuItem Header="Jump to task"
|
||||||
|
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).JumpToTaskCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Discard"
|
||||||
|
IsEnabled="{Binding IsActive}"
|
||||||
|
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).DiscardCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
<MenuItem Header="Keep"
|
||||||
|
IsEnabled="{Binding IsActive}"
|
||||||
|
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).KeepCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Copy branch"
|
||||||
|
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).CopyBranchCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
<MenuItem Header="Copy path"
|
||||||
|
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).CopyPathCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Force remove"
|
||||||
|
Foreground="#EF5350"
|
||||||
|
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ForceRemoveCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</Border.ContextMenu>
|
||||||
|
<Grid ColumnDefinitions="*,90,80,80">
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Vertical" Spacing="2">
|
||||||
|
<TextBlock Text="{Binding TaskTitle}" FontWeight="SemiBold"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
|
<TextBlock Text="{Binding TaskStatus}" FontSize="10"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"/>
|
||||||
|
<TextBlock Text="•" FontSize="10" Foreground="{DynamicResource TextFaintBrush}"
|
||||||
|
IsVisible="{Binding !PathExistsOnDisk}"/>
|
||||||
|
<TextBlock Text="phantom" FontSize="10" Foreground="#EF5350"
|
||||||
|
IsVisible="{Binding !PathExistsOnDisk}"
|
||||||
|
ToolTip.Tip="Directory missing on disk"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
<Border Grid.Column="1" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
||||||
|
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
|
||||||
|
<TextBlock Text="{Binding State}" FontSize="10" Foreground="White"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding DiffStat}" VerticalAlignment="Center"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="{Binding AgeText}" VerticalAlignment="Center"
|
||||||
|
FontSize="11" Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Window.Styles>
|
||||||
|
<Style Selector="Border.wt-row">
|
||||||
|
<Setter Property="Padding" Value="12,10"/>
|
||||||
|
<Setter Property="CornerRadius" Value="8"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,0,6"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="BorderBrush" Duration="0:0:0.10"/>
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.wt-row:pointerover">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource LineBrightBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.wt-row.selected">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource LineBrightBrush}"/>
|
||||||
|
</Style>
|
||||||
|
</Window.Styles>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="36,Auto,*,52">
|
||||||
|
|
||||||
|
<!-- Title bar -->
|
||||||
|
<Border Grid.Row="0"
|
||||||
|
Background="{DynamicResource DeepBrush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
PointerPressed="OnTitleBarPressed">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding Title}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="14,0,0,0"
|
||||||
|
FontSize="12"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="✕"
|
||||||
|
Command="{Binding CloseCommand}"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
Width="28" Height="28"
|
||||||
|
FontSize="11"
|
||||||
|
HorizontalContentAlignment="Center"
|
||||||
|
VerticalContentAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<Border Grid.Row="1"
|
||||||
|
Background="{DynamicResource DeepBrush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
Padding="12,8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Content="Refresh" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||||||
|
<Button Content="Cleanup finished" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||||||
|
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="12,0,0,0"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<ScrollViewer Grid.Row="2" Padding="12,8">
|
||||||
|
<StackPanel>
|
||||||
|
<!-- Column headers -->
|
||||||
|
<Grid ColumnDefinitions="*,90,80,80" Margin="12,0,12,4">
|
||||||
|
<TextBlock Grid.Column="0" Text="TASK"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="STATE"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="DIFF"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="AGE"
|
||||||
|
FontFamily="{DynamicResource MonoFont}" FontSize="10" LetterSpacing="1.4"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"/>
|
||||||
|
</Grid>
|
||||||
|
<Border Height="1" Background="{DynamicResource LineBrush}" Margin="0,0,0,8"/>
|
||||||
|
|
||||||
|
<!-- Rows (per-list) -->
|
||||||
|
<ItemsControl ItemsSource="{Binding Rows}" IsVisible="{Binding !IsGlobal}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:WorktreeOverviewRowViewModel">
|
||||||
|
<ContentControl ContentTemplate="{StaticResource WorktreeRowTemplate}" Content="{Binding}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- Rows (global, grouped) -->
|
||||||
|
<ItemsControl ItemsSource="{Binding Groups}" IsVisible="{Binding IsGlobal}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:WorktreesGroupViewModel">
|
||||||
|
<Expander Header="{Binding ListName}" IsExpanded="True" Margin="0,0,0,6">
|
||||||
|
<ItemsControl ItemsSource="{Binding Rows}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:WorktreeOverviewRowViewModel">
|
||||||
|
<ContentControl ContentTemplate="{StaticResource WorktreeRowTemplate}" Content="{Binding}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Expander>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Border Grid.Row="3"
|
||||||
|
Background="{DynamicResource DeepBrush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,1,0,0"
|
||||||
|
Padding="12,10">
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Content="Close" Command="{Binding CloseCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Modals;
|
||||||
|
|
||||||
|
public partial class WorktreesOverviewModalView : Window
|
||||||
|
{
|
||||||
|
public WorktreesOverviewModalView() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnRowTapped(object? sender, TappedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is Border { DataContext: WorktreeOverviewRowViewModel row } &&
|
||||||
|
DataContext is WorktreesOverviewModalViewModel vm)
|
||||||
|
{
|
||||||
|
vm.SelectRow(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
BeginMoveDrag(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,6 @@ namespace ClaudeDo.Worker.External;
|
|||||||
|
|
||||||
public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
|
public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
|
||||||
|
|
||||||
public sealed record TagDto(long Id, string Name);
|
|
||||||
|
|
||||||
public sealed record TaskDto(
|
public sealed record TaskDto(
|
||||||
string Id,
|
string Id,
|
||||||
string ListId,
|
string ListId,
|
||||||
@@ -32,7 +30,6 @@ public sealed class ExternalMcpService
|
|||||||
private readonly ListRepository _lists;
|
private readonly ListRepository _lists;
|
||||||
private readonly QueueService _queue;
|
private readonly QueueService _queue;
|
||||||
private readonly HubBroadcaster _broadcaster;
|
private readonly HubBroadcaster _broadcaster;
|
||||||
private readonly TagRepository _tags;
|
|
||||||
private readonly ITaskStateService _state;
|
private readonly ITaskStateService _state;
|
||||||
|
|
||||||
public ExternalMcpService(
|
public ExternalMcpService(
|
||||||
@@ -40,14 +37,12 @@ public sealed class ExternalMcpService
|
|||||||
ListRepository lists,
|
ListRepository lists,
|
||||||
QueueService queue,
|
QueueService queue,
|
||||||
HubBroadcaster broadcaster,
|
HubBroadcaster broadcaster,
|
||||||
TagRepository tags,
|
|
||||||
ITaskStateService state)
|
ITaskStateService state)
|
||||||
{
|
{
|
||||||
_tasks = tasks;
|
_tasks = tasks;
|
||||||
_lists = lists;
|
_lists = lists;
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
_tags = tags;
|
|
||||||
_state = state;
|
_state = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,14 +86,13 @@ public sealed class ExternalMcpService
|
|||||||
return ToDto(task);
|
return ToDto(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. Optional tags are attached on creation; missing tag names auto-create.")]
|
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution.")]
|
||||||
public async Task<TaskDto> AddTask(
|
public async Task<TaskDto> AddTask(
|
||||||
string listId,
|
string listId,
|
||||||
string title,
|
string title,
|
||||||
string? description,
|
string? description,
|
||||||
string createdBy,
|
string createdBy,
|
||||||
bool queueImmediately,
|
bool queueImmediately,
|
||||||
IReadOnlyList<string>? tags,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(listId))
|
if (string.IsNullOrWhiteSpace(listId))
|
||||||
@@ -124,9 +118,6 @@ public sealed class ExternalMcpService
|
|||||||
};
|
};
|
||||||
await _tasks.AddAsync(entity, cancellationToken);
|
await _tasks.AddAsync(entity, cancellationToken);
|
||||||
|
|
||||||
if (tags is not null && tags.Count > 0)
|
|
||||||
await _tasks.SetTagsAsync(entity.Id, tags, cancellationToken);
|
|
||||||
|
|
||||||
if (queueImmediately)
|
if (queueImmediately)
|
||||||
{
|
{
|
||||||
// Routes through TaskStateService so the queue is woken automatically.
|
// Routes through TaskStateService so the queue is woken automatically.
|
||||||
@@ -140,13 +131,12 @@ public sealed class ExternalMcpService
|
|||||||
return ToDto(entity);
|
return ToDto(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Update an existing task's title, description, commit type, and/or tags. Pass null to leave a field unchanged. Tags are replaced as a full set when non-null. Refuses if the task is currently Running.")]
|
[McpServerTool, Description("Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. Refuses if the task is currently Running.")]
|
||||||
public async Task<TaskDto> UpdateTask(
|
public async Task<TaskDto> UpdateTask(
|
||||||
string taskId,
|
string taskId,
|
||||||
string? title,
|
string? title,
|
||||||
string? description,
|
string? description,
|
||||||
string? commitType,
|
string? commitType,
|
||||||
IReadOnlyList<string>? tags,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
@@ -159,9 +149,6 @@ public sealed class ExternalMcpService
|
|||||||
if (commitType is not null) task.CommitType = commitType;
|
if (commitType is not null) task.CommitType = commitType;
|
||||||
await _tasks.UpdateAsync(task, cancellationToken);
|
await _tasks.UpdateAsync(task, cancellationToken);
|
||||||
|
|
||||||
if (tags is not null)
|
|
||||||
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
|
|
||||||
|
|
||||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
return ToDto(reload);
|
return ToDto(reload);
|
||||||
@@ -239,30 +226,6 @@ public sealed class ExternalMcpService
|
|||||||
await _broadcaster.TaskUpdated(taskId);
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Replace the full tag set on an existing task. Missing tag names auto-create. Refuses if the task is Running.")]
|
|
||||||
public async Task<TaskDto> SetTaskTags(
|
|
||||||
string taskId,
|
|
||||||
IReadOnlyList<string> tags,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
||||||
if (task.Status == TaskStatus.Running)
|
|
||||||
throw new InvalidOperationException("Cannot retag a running task. Cancel it first.");
|
|
||||||
|
|
||||||
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
|
|
||||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
|
||||||
return ToDto(reload);
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description("List all known tags. Useful for discovering existing tag names (including 'agent' which marks tasks for auto-execution) before tagging.")]
|
|
||||||
public async Task<IReadOnlyList<TagDto>> ListTags(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var tags = await _tags.GetAllAsync(cancellationToken);
|
|
||||||
return tags.Select(t => new TagDto(t.Id, t.Name)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TaskDto ToDto(TaskEntity t) => new(
|
private static TaskDto ToDto(TaskEntity t) => new(
|
||||||
t.Id,
|
t.Id,
|
||||||
t.ListId,
|
t.ListId,
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ public record AppSettingsDto(
|
|||||||
|
|
||||||
public record WorktreeCleanupDto(int Removed);
|
public record WorktreeCleanupDto(int Removed);
|
||||||
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||||
|
|
||||||
|
public record WorktreeOverviewDto(
|
||||||
|
string TaskId,
|
||||||
|
string TaskTitle,
|
||||||
|
ClaudeDo.Data.Models.TaskStatus TaskStatus,
|
||||||
|
string ListId,
|
||||||
|
string ListName,
|
||||||
|
string Path,
|
||||||
|
string BranchName,
|
||||||
|
string BaseCommit,
|
||||||
|
WorktreeState State,
|
||||||
|
string? DiffStat,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
bool PathExistsOnDisk);
|
||||||
|
|
||||||
|
public record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||||
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||||
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||||
@@ -210,9 +226,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
{
|
{
|
||||||
Id = AppSettingsEntity.SingletonId,
|
Id = AppSettingsEntity.SingletonId,
|
||||||
DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "",
|
DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "",
|
||||||
DefaultModel = dto.DefaultModel ?? "sonnet",
|
DefaultModel = dto.DefaultModel ?? ModelRegistry.DefaultAlias,
|
||||||
DefaultMaxTurns = dto.DefaultMaxTurns,
|
DefaultMaxTurns = dto.DefaultMaxTurns,
|
||||||
DefaultPermissionMode = dto.DefaultPermissionMode ?? "bypassPermissions",
|
DefaultPermissionMode = dto.DefaultPermissionMode ?? PermissionModeRegistry.DefaultMode,
|
||||||
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling",
|
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling",
|
||||||
CentralWorktreeRoot = dto.CentralWorktreeRoot,
|
CentralWorktreeRoot = dto.CentralWorktreeRoot,
|
||||||
WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled,
|
WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled,
|
||||||
@@ -220,9 +236,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees()
|
public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null)
|
||||||
{
|
{
|
||||||
var result = await _wtMaintenance.CleanupFinishedAsync();
|
var result = await _wtMaintenance.CleanupFinishedAsync(listId, Context.ConnectionAborted);
|
||||||
return new WorktreeCleanupDto(result.Removed);
|
return new WorktreeCleanupDto(result.Removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +248,33 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks);
|
return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverview(string? listId)
|
||||||
|
{
|
||||||
|
var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted);
|
||||||
|
return rows.Select(r => new WorktreeOverviewDto(
|
||||||
|
r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
|
||||||
|
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)
|
||||||
|
{
|
||||||
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var repo = new WorktreeRepository(ctx);
|
||||||
|
var existing = await repo.GetByTaskIdAsync(taskId, Context.ConnectionAborted);
|
||||||
|
if (existing is null) throw new HubException("worktree not found");
|
||||||
|
await repo.SetStateAsync(taskId, newState, Context.ConnectionAborted);
|
||||||
|
await _broadcaster.WorktreeUpdated(taskId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ForceRemoveResultDto> ForceRemoveWorktree(string taskId)
|
||||||
|
{
|
||||||
|
var result = await _wtMaintenance.ForceRemoveAsync(taskId, Context.ConnectionAborted);
|
||||||
|
if (result.Removed)
|
||||||
|
await _broadcaster.WorktreeUpdated(taskId);
|
||||||
|
return new ForceRemoveResultDto(result.Removed, result.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<MergeResultDto> MergeTask(
|
public async Task<MergeResultDto> MergeTask(
|
||||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||||
{
|
{
|
||||||
@@ -281,7 +324,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
|
|
||||||
entity.Name = dto.Name;
|
entity.Name = dto.Name;
|
||||||
entity.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir;
|
entity.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir;
|
||||||
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? "chore" : dto.DefaultCommitType;
|
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType;
|
||||||
await repo.UpdateAsync(entity);
|
await repo.UpdateAsync(entity);
|
||||||
|
|
||||||
await _broadcaster.ListUpdated(dto.Id);
|
await _broadcaster.ListUpdated(dto.Id);
|
||||||
@@ -331,41 +374,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
|
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SetTaskTags(string taskId, string[] tagNames)
|
|
||||||
{
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
||||||
var entity = await ctx.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId);
|
|
||||||
if (entity is null) throw new HubException("task not found");
|
|
||||||
|
|
||||||
var desired = (tagNames ?? Array.Empty<string>())
|
|
||||||
.Select(n => n?.Trim().ToLowerInvariant() ?? "")
|
|
||||||
.Where(n => n.Length > 0)
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
foreach (var t in entity.Tags.Where(t => !desired.Contains(t.Name)).ToList())
|
|
||||||
entity.Tags.Remove(t);
|
|
||||||
|
|
||||||
var existingByName = await ctx.Tags
|
|
||||||
.Where(t => desired.Contains(t.Name))
|
|
||||||
.ToListAsync();
|
|
||||||
foreach (var name in desired)
|
|
||||||
{
|
|
||||||
if (entity.Tags.Any(t => t.Name == name)) continue;
|
|
||||||
var tag = existingByName.FirstOrDefault(t => t.Name == name)
|
|
||||||
?? new TagEntity { Name = name };
|
|
||||||
if (tag.Id == 0) ctx.Tags.Add(tag);
|
|
||||||
entity.Tags.Add(tag);
|
|
||||||
}
|
|
||||||
await ctx.SaveChangesAsync();
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<string>> GetAllTags()
|
|
||||||
{
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
||||||
return await ctx.Tags.OrderBy(t => t.Name).Select(t => t.Name).ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)
|
public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)
|
||||||
{
|
{
|
||||||
using var ctx = _dbFactory.CreateDbContext();
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
@@ -388,7 +396,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
}
|
}
|
||||||
catch (PlanningLaunchException)
|
catch (PlanningLaunchException)
|
||||||
{
|
{
|
||||||
await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
|
// Launch failed before any children could be created; force-cleanup is safe.
|
||||||
|
await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||||
@@ -408,10 +417,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
await _launcher.LaunchInteractiveAsync(ctx, Context.ConnectionAborted);
|
await _launcher.LaunchInteractiveAsync(ctx, Context.ConnectionAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DiscardPlanningSessionAsync(string taskId)
|
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
|
||||||
{
|
{
|
||||||
await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
|
var outcome = await _planning.DiscardAsync(taskId, dequeueQueuedChildren, Context.ConnectionAborted);
|
||||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
if (outcome.Result == DiscardPlanningResult.Discarded)
|
||||||
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||||
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true)
|
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true)
|
||||||
|
|||||||
38
src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs
Normal file
38
src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Lifecycle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Startup-only sweep: dequeues queued tasks whose parent is missing or no longer
|
||||||
|
/// in a planning phase. The child stays attached (<c>ParentTaskId</c> intact) but
|
||||||
|
/// drops out of the queue so it can't run against a dead chain. The user can
|
||||||
|
/// re-queue or detach manually.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrphanRecovery : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly ILogger<OrphanRecovery> _logger;
|
||||||
|
|
||||||
|
public OrphanRecovery(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
ILogger<OrphanRecovery> logger)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var repo = new TaskRepository(ctx);
|
||||||
|
var dequeued = await repo.DequeueOrphanedChildrenAsync(cancellationToken);
|
||||||
|
if (dequeued > 0)
|
||||||
|
_logger.LogWarning("Orphan recovery: dequeued {Count} stuck child task(s)", dequeued);
|
||||||
|
else
|
||||||
|
_logger.LogInformation("Orphan recovery: no stuck child tasks found");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
69
src/ClaudeDo.Worker/Lifecycle/PlanningLineageRecovery.cs
Normal file
69
src/ClaudeDo.Worker/Lifecycle/PlanningLineageRecovery.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Lifecycle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Startup-only sweep that tries to re-attach blocked-by chains to their original
|
||||||
|
/// planning parent when the lineage was lost (parent_task_id cleared, parent
|
||||||
|
/// reverted to <c>PlanningPhase.None</c>) but the planning-sessions directory
|
||||||
|
/// still has the matching folder. Only restores lineage when the chain in the
|
||||||
|
/// parent's list is unambiguously the parent's — refuses to guess otherwise.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlanningLineageRecovery : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly string _sessionsRoot;
|
||||||
|
private readonly ILogger<PlanningLineageRecovery> _logger;
|
||||||
|
|
||||||
|
public PlanningLineageRecovery(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
string sessionsRoot,
|
||||||
|
ILogger<PlanningLineageRecovery> logger)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_sessionsRoot = sessionsRoot;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_sessionsRoot))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Planning lineage recovery: sessions directory missing, nothing to scan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var folders = Directory.GetDirectories(_sessionsRoot);
|
||||||
|
if (folders.Length == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Planning lineage recovery: no session folders");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var repo = new TaskRepository(ctx);
|
||||||
|
|
||||||
|
var restored = 0;
|
||||||
|
foreach (var folder in folders)
|
||||||
|
{
|
||||||
|
var taskId = Path.GetFileName(folder);
|
||||||
|
if (string.IsNullOrEmpty(taskId)) continue;
|
||||||
|
|
||||||
|
var count = await repo.RestorePlanningLineageAsync(taskId, cancellationToken);
|
||||||
|
if (count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Planning lineage recovery: re-attached {Count} child(ren) to parent {ParentId}",
|
||||||
|
count, taskId);
|
||||||
|
restored++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restored == 0)
|
||||||
|
_logger.LogInformation("Planning lineage recovery: no candidates");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -28,7 +28,6 @@ public sealed class PlanningChainCoordinator
|
|||||||
// chain leaves history alone but still reshapes the tail.
|
// chain leaves history alone but still reshapes the tail.
|
||||||
// - Running children abort the operation — the chain cannot be reshaped while
|
// - Running children abort the operation — the chain cannot be reshaped while
|
||||||
// one of its members is mid-flight.
|
// one of its members is mid-flight.
|
||||||
// The "agent" tag is auto-attached to every child so the picker can claim them.
|
|
||||||
// Returns the number of children placed in the chain.
|
// Returns the number of children placed in the chain.
|
||||||
internal async Task<int> SetupChainAsync(string parentTaskId, CancellationToken ct = default)
|
internal async Task<int> SetupChainAsync(string parentTaskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -37,7 +36,6 @@ public sealed class PlanningChainCoordinator
|
|||||||
?? throw new InvalidOperationException($"Task {parentTaskId} not found.");
|
?? throw new InvalidOperationException($"Task {parentTaskId} not found.");
|
||||||
|
|
||||||
var children = await ctx.Tasks
|
var children = await ctx.Tasks
|
||||||
.Include(t => t.Tags)
|
|
||||||
.Where(t => t.ParentTaskId == parentTaskId)
|
.Where(t => t.ParentTaskId == parentTaskId)
|
||||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
@@ -49,18 +47,6 @@ public sealed class PlanningChainCoordinator
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Child {running.Id} is running; cannot reshape chain.");
|
$"Child {running.Id} is running; cannot reshape chain.");
|
||||||
|
|
||||||
// Worker queue picker requires the "agent" tag — attach it so children are pickable.
|
|
||||||
var agentTag = await ctx.Tags.FirstOrDefaultAsync(t => t.Name == "agent", ct);
|
|
||||||
if (agentTag is not null)
|
|
||||||
{
|
|
||||||
foreach (var c in children)
|
|
||||||
{
|
|
||||||
if (!c.Tags.Any(t => t.Id == agentTag.Id))
|
|
||||||
c.Tags.Add(agentTag);
|
|
||||||
}
|
|
||||||
await ctx.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-shape over Idle and Queued children only; leave Done/Failed/Cancelled
|
// Re-shape over Idle and Queued children only; leave Done/Failed/Cancelled
|
||||||
// (terminal) results in place.
|
// (terminal) results in place.
|
||||||
var sequenceable = children
|
var sequenceable = children
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|||||||
|
|
||||||
namespace ClaudeDo.Worker.Planning;
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList<string> Tags);
|
public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status);
|
||||||
public sealed record CreatedChildDto(string TaskId, string Status);
|
public sealed record CreatedChildDto(string TaskId, string Status);
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
@@ -41,12 +41,11 @@ public sealed class PlanningMcpService
|
|||||||
public async Task<CreatedChildDto> CreateChildTask(
|
public async Task<CreatedChildDto> CreateChildTask(
|
||||||
string title,
|
string title,
|
||||||
string? description,
|
string? description,
|
||||||
IReadOnlyList<string>? tags,
|
|
||||||
string? commitType,
|
string? commitType,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var ctx = _contextAccessor.Current;
|
var ctx = _contextAccessor.Current;
|
||||||
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken);
|
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, cancellationToken);
|
||||||
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
|
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
|
||||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
return new CreatedChildDto(child.Id, child.Status.ToString());
|
return new CreatedChildDto(child.Id, child.Status.ToString());
|
||||||
@@ -58,24 +57,19 @@ public sealed class PlanningMcpService
|
|||||||
{
|
{
|
||||||
var ctx = _contextAccessor.Current;
|
var ctx = _contextAccessor.Current;
|
||||||
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
|
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
var list = new List<ChildTaskDto>(children.Count);
|
return children
|
||||||
foreach (var c in children)
|
.Select(c => new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString()))
|
||||||
{
|
.ToList();
|
||||||
var tagList = await _tasks.GetTagsAsync(c.Id, cancellationToken);
|
|
||||||
list.Add(new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString(), tagList.Select(t => t.Name).ToList()));
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly TaskStatus[] EditableStatuses =
|
private static readonly TaskStatus[] EditableStatuses =
|
||||||
{ TaskStatus.Idle, TaskStatus.Queued };
|
{ TaskStatus.Idle, TaskStatus.Queued };
|
||||||
|
|
||||||
[McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Idle, Queued.")]
|
[McpServerTool, Description("Update a child task in the active planning session. Can change title, description, commit type, and status. Status must be one of: Idle, Queued.")]
|
||||||
public async Task<ChildTaskDto> UpdateChildTask(
|
public async Task<ChildTaskDto> UpdateChildTask(
|
||||||
string taskId,
|
string taskId,
|
||||||
string? title,
|
string? title,
|
||||||
string? description,
|
string? description,
|
||||||
IReadOnlyList<string>? tags,
|
|
||||||
string? commitType,
|
string? commitType,
|
||||||
string? status,
|
string? status,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -101,13 +95,12 @@ public sealed class PlanningMcpService
|
|||||||
newStatus = parsed;
|
newStatus = parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _tasks.UpdateChildAsync(taskId, title, description, commitType, tags, newStatus, cancellationToken);
|
await _tasks.UpdateChildAsync(taskId, title, description, commitType, newStatus, cancellationToken);
|
||||||
|
|
||||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||||
var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
|
|
||||||
await BroadcastTaskUpdatedAsync(reload.Id, cancellationToken);
|
await BroadcastTaskUpdatedAsync(reload.Id, cancellationToken);
|
||||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList());
|
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Delete a child task in the active planning session.")]
|
[McpServerTool, Description("Delete a child task in the active planning session.")]
|
||||||
|
|||||||
@@ -236,12 +236,17 @@ public sealed class PlanningSessionManager
|
|||||||
return children.Count(c => c.Status == TaskStatus.Idle);
|
return children.Count(c => c.Status == TaskStatus.Idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DiscardAsync(string taskId, CancellationToken ct)
|
public async Task<DiscardPlanningOutcome> DiscardAsync(
|
||||||
|
string taskId,
|
||||||
|
bool dequeueQueuedChildren,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (tasks, lists, settings, ctx) = CreateRepos();
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||||
await using var __ = ctx;
|
await using var __ = ctx;
|
||||||
|
|
||||||
var ok = await tasks.DiscardPlanningAsync(taskId, ct);
|
var outcome = await tasks.DiscardPlanningAsync(taskId, dequeueQueuedChildren, ct);
|
||||||
|
if (outcome.Result != DiscardPlanningResult.Discarded)
|
||||||
|
return outcome;
|
||||||
|
|
||||||
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||||
|
|
||||||
@@ -251,8 +256,7 @@ public sealed class PlanningSessionManager
|
|||||||
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ok)
|
return outcome;
|
||||||
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
||||||
|
|||||||
@@ -7,13 +7,14 @@
|
|||||||
// No cmd /k shim — arbitrary initial-prompt content would be re-parsed by cmd.exe otherwise.
|
// No cmd /k shim — arbitrary initial-prompt content would be re-parsed by cmd.exe otherwise.
|
||||||
|
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Planning;
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher
|
public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher
|
||||||
{
|
{
|
||||||
private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill";
|
private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill";
|
||||||
private const string Model = "claude-opus-4-7";
|
private const string Model = ModelRegistry.PlanningAlias;
|
||||||
|
|
||||||
private readonly string _wtPath;
|
private readonly string _wtPath;
|
||||||
private readonly string _claudePath;
|
private readonly string _claudePath;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
|||||||
|
|
||||||
builder.Services.AddSingleton(cfg);
|
builder.Services.AddSingleton(cfg);
|
||||||
builder.Services.AddHostedService<StaleTaskRecovery>();
|
builder.Services.AddHostedService<StaleTaskRecovery>();
|
||||||
|
builder.Services.AddHostedService<OrphanRecovery>();
|
||||||
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
||||||
{
|
{
|
||||||
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||||
@@ -100,6 +101,10 @@ builder.Services.AddSingleton(sp =>
|
|||||||
sp.GetRequiredService<ITaskStateService>(),
|
sp.GetRequiredService<ITaskStateService>(),
|
||||||
sp.GetRequiredService<PlanningChainCoordinator>(),
|
sp.GetRequiredService<PlanningChainCoordinator>(),
|
||||||
planningSessionsDir));
|
planningSessionsDir));
|
||||||
|
builder.Services.AddHostedService(sp => new PlanningLineageRecovery(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
|
planningSessionsDir,
|
||||||
|
sp.GetRequiredService<ILogger<PlanningLineageRecovery>>()));
|
||||||
builder.Services.AddSingleton<IPlanningTerminalLauncher>(sp =>
|
builder.Services.AddSingleton<IPlanningTerminalLauncher>(sp =>
|
||||||
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
|
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
@@ -180,7 +185,6 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||||
externalBuilder.Services.AddScoped<TaskRepository>();
|
externalBuilder.Services.AddScoped<TaskRepository>();
|
||||||
externalBuilder.Services.AddScoped<ListRepository>();
|
externalBuilder.Services.AddScoped<ListRepository>();
|
||||||
externalBuilder.Services.AddScoped<TagRepository>();
|
|
||||||
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
||||||
externalBuilder.Services.AddMcpServer()
|
externalBuilder.Services.AddMcpServer()
|
||||||
.WithHttpTransport()
|
.WithHttpTransport()
|
||||||
|
|||||||
@@ -362,19 +362,14 @@ public sealed class TaskRunner
|
|||||||
TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct)
|
TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
AppSettingsEntity global;
|
AppSettingsEntity global;
|
||||||
bool isAgentTask;
|
|
||||||
using (var ctx = _dbFactory.CreateDbContext())
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
{
|
{
|
||||||
var settingsRepo = new AppSettingsRepository(ctx);
|
var settingsRepo = new AppSettingsRepository(ctx);
|
||||||
global = await settingsRepo.GetAsync(ct);
|
global = await settingsRepo.GetAsync(ct);
|
||||||
|
|
||||||
var taskRepo = new TaskRepository(ctx);
|
|
||||||
var tags = await taskRepo.GetEffectiveTagsAsync(task.Id, ct);
|
|
||||||
isAgentTask = tags.Any(t => string.Equals(t.Name, "agent", StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var systemFile = PromptFiles.ReadOrNull(PromptKind.System);
|
var systemFile = PromptFiles.ReadOrNull(PromptKind.System);
|
||||||
var agentFile = isAgentTask ? PromptFiles.ReadOrNull(PromptKind.Agent) : null;
|
var agentFile = PromptFiles.ReadOrNull(PromptKind.Agent);
|
||||||
|
|
||||||
var instructions = MergeInstructions(
|
var instructions = MergeInstructions(
|
||||||
systemFile, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt, agentFile);
|
systemFile, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt, agentFile);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public sealed class WorktreeMaintenanceService
|
|||||||
{
|
{
|
||||||
public sealed record CleanupResult(int Removed);
|
public sealed record CleanupResult(int Removed);
|
||||||
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||||
|
public sealed record ForceRemoveResult(bool Removed, string? Reason);
|
||||||
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
@@ -24,16 +25,19 @@ public sealed class WorktreeMaintenanceService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CleanupResult> CleanupFinishedAsync(CancellationToken ct = default)
|
public async Task<CleanupResult> CleanupFinishedAsync(string? listId = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var context = _dbFactory.CreateDbContext();
|
using var context = _dbFactory.CreateDbContext();
|
||||||
var rows = await (from w in context.Worktrees
|
var query = from w in context.Worktrees
|
||||||
join t in context.Tasks on w.TaskId equals t.Id
|
join t in context.Tasks on w.TaskId equals t.Id
|
||||||
join l in context.Lists on t.ListId equals l.Id
|
join l in context.Lists on t.ListId equals l.Id
|
||||||
where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded
|
where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded
|
||||||
select new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir))
|
select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir), ListId = t.ListId };
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(ct);
|
if (!string.IsNullOrEmpty(listId))
|
||||||
|
query = query.Where(x => x.ListId == listId);
|
||||||
|
|
||||||
|
var rows = await query.AsNoTracking().Select(x => x.Row).ToListAsync(ct);
|
||||||
|
|
||||||
int removed = 0;
|
int removed = 0;
|
||||||
foreach (var row in rows)
|
foreach (var row in rows)
|
||||||
@@ -68,6 +72,53 @@ public sealed class WorktreeMaintenanceService
|
|||||||
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0);
|
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(
|
||||||
|
string? listId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var context = _dbFactory.CreateDbContext();
|
||||||
|
var query = from w in context.Worktrees
|
||||||
|
join t in context.Tasks on w.TaskId equals t.Id
|
||||||
|
join l in context.Lists on t.ListId equals l.Id
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
w.TaskId, t.Title, t.Status, ListId = l.Id, ListName = l.Name,
|
||||||
|
w.Path, w.BranchName, w.BaseCommit, w.State, w.DiffStat, w.CreatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(listId))
|
||||||
|
query = query.Where(x => x.ListId == listId);
|
||||||
|
|
||||||
|
var rows = await query.AsNoTracking().ToListAsync(ct);
|
||||||
|
|
||||||
|
return rows.Select(x => new WorktreeOverviewRow(
|
||||||
|
x.TaskId, x.Title, x.Status, x.ListId, x.ListName,
|
||||||
|
x.Path, x.BranchName, x.BaseCommit ?? "", x.State, x.DiffStat, x.CreatedAt,
|
||||||
|
PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ForceRemoveResult> ForceRemoveAsync(string taskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var context = _dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
var row = await (from w in context.Worktrees
|
||||||
|
join t in context.Tasks on w.TaskId equals t.Id
|
||||||
|
join l in context.Lists on t.ListId equals l.Id
|
||||||
|
where w.TaskId == taskId
|
||||||
|
select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir),
|
||||||
|
Status = t.Status })
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
return new ForceRemoveResult(false, "worktree not found");
|
||||||
|
|
||||||
|
if (row.Status == ClaudeDo.Data.Models.TaskStatus.Running)
|
||||||
|
return new ForceRemoveResult(false, "task is currently running");
|
||||||
|
|
||||||
|
var ok = await TryRemoveAsync(row.Row, force: true, ct);
|
||||||
|
return new ForceRemoveResult(ok, ok ? null : "remove failed");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
|
private async Task<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
|
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
|
||||||
|
|||||||
18
src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs
Normal file
18
src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Worktrees;
|
||||||
|
|
||||||
|
public sealed record WorktreeOverviewRow(
|
||||||
|
string TaskId,
|
||||||
|
string TaskTitle,
|
||||||
|
TaskStatus TaskStatus,
|
||||||
|
string ListId,
|
||||||
|
string ListName,
|
||||||
|
string Path,
|
||||||
|
string BranchName,
|
||||||
|
string BaseCommit,
|
||||||
|
WorktreeState State,
|
||||||
|
string? DiffStat,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
bool PathExistsOnDisk);
|
||||||
27
tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj
Normal file
27
tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
49
tests/ClaudeDo.Data.Tests/Filtering/PlanningRulesTests.cs
Normal file
49
tests/ClaudeDo.Data.Tests/Filtering/PlanningRulesTests.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using ClaudeDo.Data.Filtering;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests.Filtering;
|
||||||
|
|
||||||
|
public sealed class PlanningRulesTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(PlanningPhase.None, false)]
|
||||||
|
[InlineData(PlanningPhase.Active, true)]
|
||||||
|
[InlineData(PlanningPhase.Finalized, true)]
|
||||||
|
public void IsPlanningParent_reflects_phase(PlanningPhase phase, bool expected)
|
||||||
|
{
|
||||||
|
var t = TaskFactory.Make("p", phase: phase);
|
||||||
|
Assert.Equal(expected, PlanningRules.IsPlanningParent(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasMatchingChild_true_when_a_child_matches()
|
||||||
|
{
|
||||||
|
var parent = TaskFactory.Make("p", phase: PlanningPhase.Active);
|
||||||
|
var child = TaskFactory.Make("c", parentId: "p", status: TaskStatus.Queued);
|
||||||
|
var all = new List<TaskEntity> { parent, child };
|
||||||
|
|
||||||
|
Assert.True(PlanningRules.HasMatchingChild(parent, all, c => c.Status == TaskStatus.Queued));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasMatchingChild_false_when_no_child_matches()
|
||||||
|
{
|
||||||
|
var parent = TaskFactory.Make("p", phase: PlanningPhase.Active);
|
||||||
|
var child = TaskFactory.Make("c", parentId: "p", status: TaskStatus.Done);
|
||||||
|
var all = new List<TaskEntity> { parent, child };
|
||||||
|
|
||||||
|
Assert.False(PlanningRules.HasMatchingChild(parent, all, c => c.Status == TaskStatus.Queued));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasMatchingChild_ignores_other_parents_children()
|
||||||
|
{
|
||||||
|
var parent = TaskFactory.Make("p", phase: PlanningPhase.Active);
|
||||||
|
var otherParent = TaskFactory.Make("p2", phase: PlanningPhase.Active);
|
||||||
|
var foreignKid = TaskFactory.Make("c", parentId: "p2", status: TaskStatus.Queued);
|
||||||
|
var all = new List<TaskEntity> { parent, otherParent, foreignKid };
|
||||||
|
|
||||||
|
Assert.False(PlanningRules.HasMatchingChild(parent, all, c => c.Status == TaskStatus.Queued));
|
||||||
|
}
|
||||||
|
}
|
||||||
46
tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs
Normal file
46
tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
using ClaudeDo.Data.Filtering.Filters;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests.Filtering;
|
||||||
|
|
||||||
|
public sealed class SmartFilterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MyDay_matches_my_day_tasks_regardless_of_status()
|
||||||
|
{
|
||||||
|
var f = new MyDayFilter();
|
||||||
|
Assert.True (f.Matches(TaskFactory.Make("a", isMyDay: true, status: TaskStatus.Idle)));
|
||||||
|
Assert.True (f.Matches(TaskFactory.Make("b", isMyDay: true, status: TaskStatus.Done)));
|
||||||
|
Assert.False(f.Matches(TaskFactory.Make("c", isMyDay: false, status: TaskStatus.Idle)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MyDay_count_excludes_done()
|
||||||
|
{
|
||||||
|
var f = new MyDayFilter();
|
||||||
|
Assert.True (f.ShouldCount(TaskFactory.Make("a", isMyDay: true, status: TaskStatus.Queued)));
|
||||||
|
Assert.False(f.ShouldCount(TaskFactory.Make("b", isMyDay: true, status: TaskStatus.Done)));
|
||||||
|
Assert.False(f.ShouldCount(TaskFactory.Make("c", isMyDay: false, status: TaskStatus.Idle)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Important_uses_IsStarred_with_same_split()
|
||||||
|
{
|
||||||
|
var f = new ImportantFilter();
|
||||||
|
Assert.True (f.Matches (TaskFactory.Make("a", isStarred: true, status: TaskStatus.Done)));
|
||||||
|
Assert.False(f.ShouldCount(TaskFactory.Make("a", isStarred: true, status: TaskStatus.Done)));
|
||||||
|
Assert.True (f.ShouldCount(TaskFactory.Make("b", isStarred: true, status: TaskStatus.Queued)));
|
||||||
|
Assert.False(f.Matches (TaskFactory.Make("c", isStarred: false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Planned_uses_ScheduledFor_with_same_split()
|
||||||
|
{
|
||||||
|
var f = new PlannedFilter();
|
||||||
|
var when = DateTime.Today;
|
||||||
|
Assert.True (f.Matches (TaskFactory.Make("a", scheduled: when, status: TaskStatus.Done)));
|
||||||
|
Assert.False(f.ShouldCount(TaskFactory.Make("a", scheduled: when, status: TaskStatus.Done)));
|
||||||
|
Assert.True (f.ShouldCount(TaskFactory.Make("b", scheduled: when, status: TaskStatus.Idle)));
|
||||||
|
Assert.False(f.Matches (TaskFactory.Make("c", scheduled: null)));
|
||||||
|
}
|
||||||
|
}
|
||||||
46
tests/ClaudeDo.Data.Tests/Filtering/TaskFactory.cs
Normal file
46
tests/ClaudeDo.Data.Tests/Filtering/TaskFactory.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests.Filtering;
|
||||||
|
|
||||||
|
internal static class TaskFactory
|
||||||
|
{
|
||||||
|
public static TaskEntity Make(
|
||||||
|
string id,
|
||||||
|
string listId = "L",
|
||||||
|
TaskStatus status = TaskStatus.Idle,
|
||||||
|
PlanningPhase phase = PlanningPhase.None,
|
||||||
|
string? parentId = null,
|
||||||
|
bool isMyDay = false,
|
||||||
|
bool isStarred = false,
|
||||||
|
DateTime? scheduled = null,
|
||||||
|
WorktreeState? worktreeState = null)
|
||||||
|
{
|
||||||
|
var t = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
ListId = listId,
|
||||||
|
Title = id,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = status,
|
||||||
|
PlanningPhase = phase,
|
||||||
|
ParentTaskId = parentId,
|
||||||
|
IsMyDay = isMyDay,
|
||||||
|
IsStarred = isStarred,
|
||||||
|
ScheduledFor = scheduled,
|
||||||
|
};
|
||||||
|
if (worktreeState is { } s)
|
||||||
|
{
|
||||||
|
t.Worktree = new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = id,
|
||||||
|
Path = "/tmp/" + id,
|
||||||
|
BranchName = "br/" + id,
|
||||||
|
BaseCommit = "deadbeef",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
State = s,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using ClaudeDo.Data.Filtering;
|
||||||
|
using ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests.Filtering;
|
||||||
|
|
||||||
|
public sealed class TaskListFilterRegistryTests
|
||||||
|
{
|
||||||
|
private readonly TaskListFilterRegistry _registry = new();
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("smart:my-day", typeof(MyDayFilter))]
|
||||||
|
[InlineData("smart:important", typeof(ImportantFilter))]
|
||||||
|
[InlineData("smart:planned", typeof(PlannedFilter))]
|
||||||
|
[InlineData("virtual:queued", typeof(QueuedFilter))]
|
||||||
|
[InlineData("virtual:running", typeof(RunningFilter))]
|
||||||
|
[InlineData("virtual:review", typeof(ReviewFilter))]
|
||||||
|
public void Resolves_known_built_in_filters(string id, Type expected)
|
||||||
|
{
|
||||||
|
var f = _registry.Resolve(id);
|
||||||
|
Assert.NotNull(f);
|
||||||
|
Assert.IsType(expected, f);
|
||||||
|
Assert.Equal(id, f!.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolves_user_list_filter_from_prefixed_id()
|
||||||
|
{
|
||||||
|
var f = _registry.Resolve("user:abc123");
|
||||||
|
var user = Assert.IsType<UserListFilter>(f);
|
||||||
|
Assert.Equal("user:abc123", user.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Returns_null_for_unknown_or_empty_user_id()
|
||||||
|
{
|
||||||
|
Assert.Null(_registry.Resolve("bogus"));
|
||||||
|
Assert.Null(_registry.Resolve("user:"));
|
||||||
|
}
|
||||||
|
}
|
||||||
31
tests/ClaudeDo.Data.Tests/Filtering/UserListFilterTests.cs
Normal file
31
tests/ClaudeDo.Data.Tests/Filtering/UserListFilterTests.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using ClaudeDo.Data.Filtering.Filters;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests.Filtering;
|
||||||
|
|
||||||
|
public sealed class UserListFilterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Id_uses_user_prefix()
|
||||||
|
{
|
||||||
|
var f = new UserListFilter("inbox");
|
||||||
|
Assert.Equal("user:inbox", f.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Matches_only_tasks_in_the_owning_list()
|
||||||
|
{
|
||||||
|
var f = new UserListFilter("inbox");
|
||||||
|
Assert.True (f.Matches(TaskFactory.Make("a", listId: "inbox")));
|
||||||
|
Assert.False(f.Matches(TaskFactory.Make("b", listId: "other")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Count_excludes_done_tasks()
|
||||||
|
{
|
||||||
|
var f = new UserListFilter("inbox");
|
||||||
|
Assert.True (f.ShouldCount(TaskFactory.Make("a", listId: "inbox", status: TaskStatus.Idle)));
|
||||||
|
Assert.False(f.ShouldCount(TaskFactory.Make("b", listId: "inbox", status: TaskStatus.Done)));
|
||||||
|
Assert.False(f.ShouldCount(TaskFactory.Make("c", listId: "other", status: TaskStatus.Idle)));
|
||||||
|
}
|
||||||
|
}
|
||||||
97
tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs
Normal file
97
tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
using ClaudeDo.Data.Filtering.Filters;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests.Filtering;
|
||||||
|
|
||||||
|
public sealed class VirtualFilterTests
|
||||||
|
{
|
||||||
|
// --- Queued ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Queued_matches_every_queued_task_regardless_of_parent()
|
||||||
|
{
|
||||||
|
var f = new QueuedFilter();
|
||||||
|
Assert.True (f.Matches(TaskFactory.Make("a", status: TaskStatus.Queued)));
|
||||||
|
Assert.True (f.Matches(TaskFactory.Make("b", status: TaskStatus.Queued, parentId: "p")));
|
||||||
|
Assert.False(f.Matches(TaskFactory.Make("c", status: TaskStatus.Running)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Queued_count_equals_match_for_top_level_and_children_alike()
|
||||||
|
{
|
||||||
|
// The point of the consolidation: counter must agree with display set.
|
||||||
|
var f = new QueuedFilter();
|
||||||
|
var orphan = TaskFactory.Make("o", status: TaskStatus.Queued, parentId: "missing");
|
||||||
|
Assert.True(f.Matches(orphan));
|
||||||
|
Assert.True(f.ShouldCount(orphan));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Queued_planning_parent_with_queued_kid_is_context_match()
|
||||||
|
{
|
||||||
|
var f = new QueuedFilter();
|
||||||
|
var parent = TaskFactory.Make("p", phase: PlanningPhase.Active, status: TaskStatus.Done);
|
||||||
|
var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Queued);
|
||||||
|
var all = new List<TaskEntity> { parent, kid };
|
||||||
|
|
||||||
|
Assert.False(f.Matches(parent));
|
||||||
|
Assert.True (f.MatchesAsContext(parent, all));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Queued_non_planning_parent_is_never_context_match()
|
||||||
|
{
|
||||||
|
var f = new QueuedFilter();
|
||||||
|
var parent = TaskFactory.Make("p", phase: PlanningPhase.None, status: TaskStatus.Done);
|
||||||
|
var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Queued);
|
||||||
|
var all = new List<TaskEntity> { parent, kid };
|
||||||
|
|
||||||
|
Assert.False(f.MatchesAsContext(parent, all));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Queued_planning_parent_without_queued_kid_is_not_context_match()
|
||||||
|
{
|
||||||
|
var f = new QueuedFilter();
|
||||||
|
var parent = TaskFactory.Make("p", phase: PlanningPhase.Active);
|
||||||
|
var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Done);
|
||||||
|
var all = new List<TaskEntity> { parent, kid };
|
||||||
|
|
||||||
|
Assert.False(f.MatchesAsContext(parent, all));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Running mirrors Queued ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Running_matches_and_context_mirror_queued()
|
||||||
|
{
|
||||||
|
var f = new RunningFilter();
|
||||||
|
var parent = TaskFactory.Make("p", phase: PlanningPhase.Active);
|
||||||
|
var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Running);
|
||||||
|
var all = new List<TaskEntity> { parent, kid };
|
||||||
|
|
||||||
|
Assert.True (f.Matches(kid));
|
||||||
|
Assert.True (f.MatchesAsContext(parent, all));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Review ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Review_matches_only_done_with_active_worktree()
|
||||||
|
{
|
||||||
|
var f = new ReviewFilter();
|
||||||
|
Assert.True (f.Matches(TaskFactory.Make("a", status: TaskStatus.Done, worktreeState: WorktreeState.Active)));
|
||||||
|
Assert.False(f.Matches(TaskFactory.Make("b", status: TaskStatus.Done, worktreeState: WorktreeState.Merged)));
|
||||||
|
Assert.False(f.Matches(TaskFactory.Make("c", status: TaskStatus.Done, worktreeState: null)));
|
||||||
|
Assert.False(f.Matches(TaskFactory.Make("d", status: TaskStatus.Failed, worktreeState: WorktreeState.Active)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Review_count_equals_match()
|
||||||
|
{
|
||||||
|
var f = new ReviewFilter();
|
||||||
|
var t = TaskFactory.Make("a", status: TaskStatus.Done, worktreeState: WorktreeState.Active);
|
||||||
|
Assert.Equal(f.Matches(t), f.ShouldCount(t));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ public class ConflictResolutionViewModelTests
|
|||||||
public event Action<string, string, DateTime>? TaskStartedEvent;
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
@@ -42,7 +43,8 @@ public class ConflictResolutionViewModelTests
|
|||||||
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
||||||
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task<ClaudeDo.Data.Repositories.DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new ClaudeDo.Data.Repositories.DiscardPlanningOutcome(ClaudeDo.Data.Repositories.DiscardPlanningResult.Discarded, 0, 0));
|
||||||
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public class DetailsIslandPlanningTests : IDisposable
|
|||||||
public event Action<string, string, DateTime>? TaskStartedEvent;
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
@@ -72,7 +73,8 @@ public class DetailsIslandPlanningTests : IDisposable
|
|||||||
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
||||||
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task<ClaudeDo.Data.Repositories.DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new ClaudeDo.Data.Repositories.DiscardPlanningOutcome(ClaudeDo.Data.Repositories.DiscardPlanningResult.Discarded, 0, 0));
|
||||||
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult(MergeTargetsResult);
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult(MergeTargetsResult);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class PlanningDiffViewModelTests
|
|||||||
public event Action<string, string, DateTime>? TaskStartedEvent;
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
@@ -39,7 +40,8 @@ public class PlanningDiffViewModelTests
|
|||||||
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
||||||
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task<ClaudeDo.Data.Repositories.DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new ClaudeDo.Data.Repositories.DiscardPlanningOutcome(ClaudeDo.Data.Repositories.DiscardPlanningResult.Discarded, 0, 0));
|
||||||
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
private readonly ClaudeDoDbContext _ctx;
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly TaskRepository _tasks;
|
private readonly TaskRepository _tasks;
|
||||||
private readonly ListRepository _lists;
|
private readonly ListRepository _lists;
|
||||||
private readonly TagRepository _tags;
|
|
||||||
private readonly ExternalFakeHubContext _hub = new();
|
private readonly ExternalFakeHubContext _hub = new();
|
||||||
private readonly HubBroadcaster _broadcaster;
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
|
||||||
@@ -61,7 +60,6 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
_ctx = _db.CreateContext();
|
_ctx = _db.CreateContext();
|
||||||
_tasks = new TaskRepository(_ctx);
|
_tasks = new TaskRepository(_ctx);
|
||||||
_lists = new ListRepository(_ctx);
|
_lists = new ListRepository(_ctx);
|
||||||
_tags = new TagRepository(_ctx);
|
|
||||||
_broadcaster = new HubBroadcaster(_hub);
|
_broadcaster = new HubBroadcaster(_hub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +87,8 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueueService is needed by ExternalMcpService's constructor. For tests that
|
|
||||||
// only exercise UpdateTask / DeleteTask / SetTaskTags / ListTags / ListTags,
|
|
||||||
// we never call its WakeQueue/RunNow/CancelTask paths, so a real QueueService
|
|
||||||
// built with the same approach used in QueueServiceTests is sufficient.
|
|
||||||
private ExternalMcpService BuildSut(QueueService queue) =>
|
private ExternalMcpService BuildSut(QueueService queue) =>
|
||||||
new(_tasks, _lists, queue, _broadcaster, _tags,
|
new(_tasks, _lists, queue, _broadcaster,
|
||||||
TaskStateServiceBuilder.Build(_db.CreateFactory()).State);
|
TaskStateServiceBuilder.Build(_db.CreateFactory()).State);
|
||||||
|
|
||||||
private QueueService CreateQueue()
|
private QueueService CreateQueue()
|
||||||
@@ -129,54 +123,6 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
Assert.NotNull(await _tasks.GetByIdAsync(task.Id));
|
Assert.NotNull(await _tasks.GetByIdAsync(task.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ListTags_ReturnsSeededAndCustomTags()
|
|
||||||
{
|
|
||||||
var listId = await SeedListAsync();
|
|
||||||
var task = await SeedTaskAsync(listId);
|
|
||||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "custom-tag" });
|
|
||||||
|
|
||||||
var queue = CreateQueue();
|
|
||||||
var sut = BuildSut(queue);
|
|
||||||
|
|
||||||
var tags = await sut.ListTags(CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Contains(tags, t => t.Name == "agent");
|
|
||||||
Assert.Contains(tags, t => t.Name == "custom-tag");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AddTask_WithTags_AttachesTags()
|
|
||||||
{
|
|
||||||
var listId = await SeedListAsync();
|
|
||||||
var queue = CreateQueue();
|
|
||||||
var sut = BuildSut(queue);
|
|
||||||
|
|
||||||
var dto = await sut.AddTask(
|
|
||||||
listId, "scope-creep handoff", "desc", "claude-cli",
|
|
||||||
queueImmediately: false,
|
|
||||||
tags: new[] { "agent", "custom" },
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
var tags = await _tasks.GetTagsAsync(dto.Id);
|
|
||||||
Assert.Contains(tags, t => t.Name == "agent");
|
|
||||||
Assert.Contains(tags, t => t.Name == "custom");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AddTask_NullTags_BehavesAsBefore()
|
|
||||||
{
|
|
||||||
var listId = await SeedListAsync();
|
|
||||||
var queue = CreateQueue();
|
|
||||||
var sut = BuildSut(queue);
|
|
||||||
|
|
||||||
var dto = await sut.AddTask(
|
|
||||||
listId, "no tags", null, "claude-cli",
|
|
||||||
queueImmediately: false, tags: null, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Empty(await _tasks.GetTagsAsync(dto.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateTask_PatchesNonNullFieldsOnly()
|
public async Task UpdateTask_PatchesNonNullFieldsOnly()
|
||||||
{
|
{
|
||||||
@@ -185,29 +131,13 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var queue = CreateQueue();
|
var queue = CreateQueue();
|
||||||
var sut = BuildSut(queue);
|
var sut = BuildSut(queue);
|
||||||
|
|
||||||
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None);
|
var dto = await sut.UpdateTask(task.Id, "new title", null, null, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("new title", dto.Title);
|
Assert.Equal("new title", dto.Title);
|
||||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
Assert.Equal("new title", loaded!.Title);
|
Assert.Equal("new title", loaded!.Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task UpdateTask_TagsReplaceFullSet()
|
|
||||||
{
|
|
||||||
var listId = await SeedListAsync();
|
|
||||||
var task = await SeedTaskAsync(listId);
|
|
||||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
|
||||||
var queue = CreateQueue();
|
|
||||||
var sut = BuildSut(queue);
|
|
||||||
|
|
||||||
await sut.UpdateTask(task.Id, null, null, null, new[] { "manual" }, CancellationToken.None);
|
|
||||||
|
|
||||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
|
||||||
Assert.Single(tags);
|
|
||||||
Assert.Equal("manual", tags[0].Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateTask_OnRunning_Throws()
|
public async Task UpdateTask_OnRunning_Throws()
|
||||||
{
|
{
|
||||||
@@ -217,7 +147,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var sut = BuildSut(queue);
|
var sut = BuildSut(queue);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None));
|
sut.UpdateTask(task.Id, "x", null, null, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -227,15 +157,14 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var sut = BuildSut(queue);
|
var sut = BuildSut(queue);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None));
|
sut.UpdateTask("does-not-exist", "x", null, null, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteTask_RemovesTaskAndTagJoins()
|
public async Task DeleteTask_RemovesTask()
|
||||||
{
|
{
|
||||||
var listId = await SeedListAsync();
|
var listId = await SeedListAsync();
|
||||||
var task = await SeedTaskAsync(listId);
|
var task = await SeedTaskAsync(listId);
|
||||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
|
||||||
var queue = CreateQueue();
|
var queue = CreateQueue();
|
||||||
var sut = BuildSut(queue);
|
var sut = BuildSut(queue);
|
||||||
|
|
||||||
@@ -265,34 +194,4 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
sut.DeleteTask("does-not-exist", CancellationToken.None));
|
sut.DeleteTask("does-not-exist", CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SetTaskTags_ReplacesTagSetAndBroadcasts()
|
|
||||||
{
|
|
||||||
var listId = await SeedListAsync();
|
|
||||||
var task = await SeedTaskAsync(listId);
|
|
||||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
|
||||||
var queue = CreateQueue();
|
|
||||||
var sut = BuildSut(queue);
|
|
||||||
|
|
||||||
var dto = await sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None);
|
|
||||||
|
|
||||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
|
||||||
Assert.Single(tags);
|
|
||||||
Assert.Equal("manual", tags[0].Name);
|
|
||||||
Assert.Contains(_hub.RecordingClients.Proxy.Calls,
|
|
||||||
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == task.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SetTaskTags_OnRunning_Throws()
|
|
||||||
{
|
|
||||||
var listId = await SeedListAsync();
|
|
||||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
|
||||||
var queue = CreateQueue();
|
|
||||||
var sut = BuildSut(queue);
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
||||||
sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ public sealed class PlanningHubTests : IDisposable
|
|||||||
{
|
{
|
||||||
var (_, taskId) = await SeedAsync();
|
var (_, taskId) = await SeedAsync();
|
||||||
await _planning.StartAsync(taskId, CancellationToken.None);
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||||
await _tasks.CreateChildAsync(taskId, "child 1", null, null, null);
|
await _tasks.CreateChildAsync(taskId, "child 1", null, null);
|
||||||
await _tasks.CreateChildAsync(taskId, "child 2", null, null, null);
|
await _tasks.CreateChildAsync(taskId, "child 2", null, null);
|
||||||
_proxy.Sent.Clear();
|
_proxy.Sent.Clear();
|
||||||
|
|
||||||
var hub = CreateHub();
|
var hub = CreateHub();
|
||||||
@@ -158,8 +158,8 @@ public sealed class PlanningHubTests : IDisposable
|
|||||||
{
|
{
|
||||||
var (_, taskId) = await SeedAsync();
|
var (_, taskId) = await SeedAsync();
|
||||||
await _planning.StartAsync(taskId, CancellationToken.None);
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||||
await _tasks.CreateChildAsync(taskId, "c1", null, null, null);
|
await _tasks.CreateChildAsync(taskId, "c1", null, null);
|
||||||
await _tasks.CreateChildAsync(taskId, "c2", null, null, null);
|
await _tasks.CreateChildAsync(taskId, "c2", null, null);
|
||||||
|
|
||||||
var hub = CreateHub();
|
var hub = CreateHub();
|
||||||
var count = await hub.GetPendingDraftCountAsync(taskId);
|
var count = await hub.GetPendingDraftCountAsync(taskId);
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
await using var ctx = _factory.CreateDbContext();
|
await using var ctx = _factory.CreateDbContext();
|
||||||
return await ctx.Tasks
|
return await ctx.Tasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Tags)
|
|
||||||
.Where(t => t.ParentTaskId == parentId)
|
.Where(t => t.ParentTaskId == parentId)
|
||||||
.OrderBy(t => t.SortOrder)
|
.OrderBy(t => t.SortOrder)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -88,17 +87,6 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
|
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SetupChain_AttachesAgentTagToAllChildren()
|
|
||||||
{
|
|
||||||
await SeedPlanningFamilyAsync("P", 2);
|
|
||||||
|
|
||||||
await _sut.SetupChainAsync("P", default);
|
|
||||||
|
|
||||||
var kids = await GetChildrenAsync("P");
|
|
||||||
Assert.All(kids, k => Assert.Contains(k.Tags, t => t.Name == "agent"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SetupChain_AcceptsIdleChildren()
|
public async Task SetupChain_AcceptsIdleChildren()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ public sealed class PlanningEndToEndTests : IDisposable
|
|||||||
// Wire the ambient context so _svc reads the correct parent
|
// Wire the ambient context so _svc reads the correct parent
|
||||||
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
||||||
|
|
||||||
await _svc.CreateChildTask("sub 1", null, null, null, CancellationToken.None);
|
await _svc.CreateChildTask("sub 1", null, null, CancellationToken.None);
|
||||||
await _svc.CreateChildTask("sub 2", null, null, null, CancellationToken.None);
|
await _svc.CreateChildTask("sub 2", null, null, CancellationToken.None);
|
||||||
|
|
||||||
var count = await _svc.Finalize(true, CancellationToken.None);
|
var count = await _svc.Finalize(true, CancellationToken.None);
|
||||||
Assert.Equal(2, count);
|
Assert.Equal(2, count);
|
||||||
@@ -154,9 +154,9 @@ public sealed class PlanningEndToEndTests : IDisposable
|
|||||||
await _manager.StartAsync(parent.Id, CancellationToken.None);
|
await _manager.StartAsync(parent.Id, CancellationToken.None);
|
||||||
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
||||||
|
|
||||||
await _svc.CreateChildTask("c1", null, null, null, CancellationToken.None);
|
await _svc.CreateChildTask("c1", null, null, CancellationToken.None);
|
||||||
await _svc.CreateChildTask("c2", null, null, null, CancellationToken.None);
|
await _svc.CreateChildTask("c2", null, null, CancellationToken.None);
|
||||||
await _svc.CreateChildTask("c3", null, null, null, CancellationToken.None);
|
await _svc.CreateChildTask("c3", null, null, CancellationToken.None);
|
||||||
|
|
||||||
var kidsBefore = await _tasks.GetChildrenAsync(parent.Id);
|
var kidsBefore = await _tasks.GetChildrenAsync(parent.Id);
|
||||||
var firstChildId = kidsBefore[0].Id;
|
var firstChildId = kidsBefore[0].Id;
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
|||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
|
|
||||||
var result = await sut.CreateChildTask("My child", "desc", null, null, CancellationToken.None);
|
var result = await sut.CreateChildTask("My child", "desc", null, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("Idle", result.Status);
|
Assert.Equal("Idle", result.Status);
|
||||||
var child = await _tasks.GetByIdAsync(result.TaskId);
|
var child = await _tasks.GetByIdAsync(result.TaskId);
|
||||||
@@ -122,8 +122,8 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
|||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
var other = await SeedPlanningParentAsync();
|
var other = await SeedPlanningParentAsync();
|
||||||
|
|
||||||
await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null);
|
await _tasks.CreateChildAsync(parent.Id, "mine", null, null);
|
||||||
await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null);
|
await _tasks.CreateChildAsync(other.Id, "theirs", null, null);
|
||||||
|
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
var list = await sut.ListChildTasks(CancellationToken.None);
|
var list = await sut.ListChildTasks(CancellationToken.None);
|
||||||
@@ -136,18 +136,18 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
var other = await SeedPlanningParentAsync();
|
var other = await SeedPlanningParentAsync();
|
||||||
var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null);
|
var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null);
|
||||||
|
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
sut.UpdateChildTask(otherChild.Id, "new", null, null, null, null, CancellationToken.None));
|
sut.UpdateChildTask(otherChild.Id, "new", null, null, null, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateChildTask_AfterFinalize_Throws()
|
public async Task UpdateChildTask_AfterFinalize_Throws()
|
||||||
{
|
{
|
||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
// Simulate post-finalize state directly: parent.PlanningPhase=Finalized
|
// Simulate post-finalize state directly: parent.PlanningPhase=Finalized
|
||||||
// is the gate the MCP service checks.
|
// is the gate the MCP service checks.
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
@@ -155,47 +155,18 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
|||||||
Assert.True(result.Ok, result.Reason);
|
Assert.True(result.Ok, result.Reason);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
sut.UpdateChildTask(c.Id, "new", null, null, null, null, CancellationToken.None));
|
sut.UpdateChildTask(c.Id, "new", null, null, null, CancellationToken.None));
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task UpdateChildTask_SetsTags()
|
|
||||||
{
|
|
||||||
var parent = await SeedPlanningParentAsync();
|
|
||||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
|
||||||
_ctx.ChangeTracker.Clear();
|
|
||||||
|
|
||||||
var sut = BuildSut(parent.Id);
|
|
||||||
var result = await sut.UpdateChildTask(c.Id, null, null, new[] { "agent", "custom-tag" }, null, null, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Contains("agent", result.Tags);
|
|
||||||
Assert.Contains("custom-tag", result.Tags);
|
|
||||||
Assert.Equal(2, result.Tags.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task UpdateChildTask_ReplacesTagSet()
|
|
||||||
{
|
|
||||||
var parent = await SeedPlanningParentAsync();
|
|
||||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, new[] { "agent" }, null);
|
|
||||||
_ctx.ChangeTracker.Clear();
|
|
||||||
|
|
||||||
var sut = BuildSut(parent.Id);
|
|
||||||
var result = await sut.UpdateChildTask(c.Id, null, null, new[] { "manual" }, null, null, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Single(result.Tags);
|
|
||||||
Assert.Equal("manual", result.Tags[0]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateChildTask_SetsStatus()
|
public async Task UpdateChildTask_SetsStatus()
|
||||||
{
|
{
|
||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
_ctx.ChangeTracker.Clear();
|
_ctx.ChangeTracker.Clear();
|
||||||
|
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
var result = await sut.UpdateChildTask(c.Id, null, null, null, null, "Queued", CancellationToken.None);
|
var result = await sut.UpdateChildTask(c.Id, null, null, null, "Queued", CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("Queued", result.Status);
|
Assert.Equal("Queued", result.Status);
|
||||||
var loaded = await _tasks.GetByIdAsync(c.Id);
|
var loaded = await _tasks.GetByIdAsync(c.Id);
|
||||||
@@ -206,31 +177,31 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
|||||||
public async Task UpdateChildTask_DisallowedStatus_Throws()
|
public async Task UpdateChildTask_DisallowedStatus_Throws()
|
||||||
{
|
{
|
||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
_ctx.ChangeTracker.Clear();
|
_ctx.ChangeTracker.Clear();
|
||||||
|
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
sut.UpdateChildTask(c.Id, null, null, null, null, "Running", CancellationToken.None));
|
sut.UpdateChildTask(c.Id, null, null, null, "Running", CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateChildTask_UnknownStatus_Throws()
|
public async Task UpdateChildTask_UnknownStatus_Throws()
|
||||||
{
|
{
|
||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
_ctx.ChangeTracker.Clear();
|
_ctx.ChangeTracker.Clear();
|
||||||
|
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
sut.UpdateChildTask(c.Id, null, null, null, null, "NotARealStatus", CancellationToken.None));
|
sut.UpdateChildTask(c.Id, null, null, null, "NotARealStatus", CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteChildTask_RemovesDraft()
|
public async Task DeleteChildTask_RemovesDraft()
|
||||||
{
|
{
|
||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
|
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
await sut.DeleteChildTask(c.Id, CancellationToken.None);
|
await sut.DeleteChildTask(c.Id, CancellationToken.None);
|
||||||
@@ -255,8 +226,8 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
|||||||
public async Task Finalize_PromotesDraftsAndInvalidatesToken()
|
public async Task Finalize_PromotesDraftsAndInvalidatesToken()
|
||||||
{
|
{
|
||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
|
||||||
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
|
||||||
|
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
var count = await sut.Finalize(true, CancellationToken.None);
|
var count = await sut.Finalize(true, CancellationToken.None);
|
||||||
@@ -273,7 +244,7 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
|||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
|
|
||||||
var result = await sut.CreateChildTask("c", null, null, null, CancellationToken.None);
|
var result = await sut.CreateChildTask("c", null, null, CancellationToken.None);
|
||||||
|
|
||||||
var ids = TaskUpdatedIds();
|
var ids = TaskUpdatedIds();
|
||||||
Assert.Contains(result.TaskId, ids);
|
Assert.Contains(result.TaskId, ids);
|
||||||
@@ -284,11 +255,11 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
|||||||
public async Task UpdateChildTask_BroadcastsBothChildAndParent()
|
public async Task UpdateChildTask_BroadcastsBothChildAndParent()
|
||||||
{
|
{
|
||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
_ctx.ChangeTracker.Clear();
|
_ctx.ChangeTracker.Clear();
|
||||||
|
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
await sut.UpdateChildTask(c.Id, "new title", null, null, null, null, CancellationToken.None);
|
await sut.UpdateChildTask(c.Id, "new title", null, null, null, CancellationToken.None);
|
||||||
|
|
||||||
var ids = TaskUpdatedIds();
|
var ids = TaskUpdatedIds();
|
||||||
Assert.Contains(c.Id, ids);
|
Assert.Contains(c.Id, ids);
|
||||||
@@ -299,7 +270,7 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
|||||||
public async Task DeleteChildTask_BroadcastsBothChildAndParent()
|
public async Task DeleteChildTask_BroadcastsBothChildAndParent()
|
||||||
{
|
{
|
||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
|
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
await sut.DeleteChildTask(c.Id, CancellationToken.None);
|
await sut.DeleteChildTask(c.Id, CancellationToken.None);
|
||||||
@@ -313,8 +284,8 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
|||||||
public async Task Finalize_BroadcastsEachChildAndParent()
|
public async Task Finalize_BroadcastsEachChildAndParent()
|
||||||
{
|
{
|
||||||
var parent = await SeedPlanningParentAsync();
|
var parent = await SeedPlanningParentAsync();
|
||||||
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
|
||||||
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
|
||||||
|
|
||||||
var sut = BuildSut(parent.Id);
|
var sut = BuildSut(parent.Id);
|
||||||
await sut.Finalize(true, CancellationToken.None);
|
await sut.Finalize(true, CancellationToken.None);
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
|||||||
var (listId, _) = await SeedListAsync();
|
var (listId, _) = await SeedListAsync();
|
||||||
var parent = await SeedManualTaskAsync(listId);
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
await _tasks.SetPlanningStartedAsync(parent.Id, "t");
|
await _tasks.SetPlanningStartedAsync(parent.Id, "t");
|
||||||
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
_sut.StartAsync(child.Id, CancellationToken.None));
|
_sut.StartAsync(child.Id, CancellationToken.None));
|
||||||
@@ -182,8 +182,8 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
|||||||
var (listId, _) = await SeedListAsync();
|
var (listId, _) = await SeedListAsync();
|
||||||
var parent = await SeedManualTaskAsync(listId);
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
|
||||||
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
|
||||||
|
|
||||||
var count = await _sut.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
|
var count = await _sut.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
|
||||||
|
|
||||||
@@ -200,9 +200,9 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
|||||||
var (listId, _) = await SeedListAsync();
|
var (listId, _) = await SeedListAsync();
|
||||||
var parent = await SeedManualTaskAsync(listId);
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
|
||||||
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
|
||||||
await _tasks.CreateChildAsync(parent.Id, "c3", null, null, null);
|
await _tasks.CreateChildAsync(parent.Id, "c3", null, null);
|
||||||
|
|
||||||
var n = await _sut.GetPendingDraftCountAsync(parent.Id, CancellationToken.None);
|
var n = await _sut.GetPendingDraftCountAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
|||||||
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
Assert.True(Directory.Exists(startCtx.Files.SessionDirectory));
|
Assert.True(Directory.Exists(startCtx.Files.SessionDirectory));
|
||||||
|
|
||||||
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
|
await _sut.DiscardAsync(parent.Id, dequeueQueuedChildren: false, CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(Directory.Exists(startCtx.Files.SessionDirectory));
|
Assert.False(Directory.Exists(startCtx.Files.SessionDirectory));
|
||||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
@@ -235,7 +235,7 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
|||||||
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||||
|
|
||||||
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
|
await _sut.DiscardAsync(parent.Id, dequeueQueuedChildren: false, CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(Directory.Exists(ctx.WorktreePath));
|
Assert.False(Directory.Exists(ctx.WorktreePath));
|
||||||
// branch deleted
|
// branch deleted
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
private readonly ClaudeDoDbContext _ctx;
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly TaskRepository _tasks;
|
private readonly TaskRepository _tasks;
|
||||||
private readonly ListRepository _lists;
|
private readonly ListRepository _lists;
|
||||||
private readonly TagRepository _tags;
|
|
||||||
private readonly QueuePicker _picker;
|
private readonly QueuePicker _picker;
|
||||||
|
|
||||||
public QueuePickerTests()
|
public QueuePickerTests()
|
||||||
@@ -21,7 +20,6 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
_ctx = _db.CreateContext();
|
_ctx = _db.CreateContext();
|
||||||
_tasks = new TaskRepository(_ctx);
|
_tasks = new TaskRepository(_ctx);
|
||||||
_lists = new ListRepository(_ctx);
|
_lists = new ListRepository(_ctx);
|
||||||
_tags = new TagRepository(_ctx);
|
|
||||||
_picker = new QueuePicker(_db.CreateFactory());
|
_picker = new QueuePicker(_db.CreateFactory());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,11 +38,6 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
Name = "Test",
|
Name = "Test",
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
});
|
});
|
||||||
if (listAgentTag)
|
|
||||||
{
|
|
||||||
var tagId = await _tags.GetOrCreateAsync("agent");
|
|
||||||
await _lists.AddTagAsync(listId, tagId);
|
|
||||||
}
|
|
||||||
return listId;
|
return listId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,11 +62,6 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
CommitType = "feat",
|
CommitType = "feat",
|
||||||
};
|
};
|
||||||
await _tasks.AddAsync(task);
|
await _tasks.AddAsync(task);
|
||||||
if (taskAgentTag)
|
|
||||||
{
|
|
||||||
var tagId = await _tags.GetOrCreateAsync("agent");
|
|
||||||
await _tasks.AddTagAsync(task.Id, tagId);
|
|
||||||
}
|
|
||||||
if (sortOrder is not null)
|
if (sortOrder is not null)
|
||||||
{
|
{
|
||||||
task.SortOrder = sortOrder.Value;
|
task.SortOrder = sortOrder.Value;
|
||||||
|
|||||||
@@ -10,13 +10,11 @@ public sealed class ListRepositoryTests : IDisposable
|
|||||||
private readonly DbFixture _db = new();
|
private readonly DbFixture _db = new();
|
||||||
private readonly ClaudeDoDbContext _ctx;
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly ListRepository _lists;
|
private readonly ListRepository _lists;
|
||||||
private readonly TagRepository _tags;
|
|
||||||
|
|
||||||
public ListRepositoryTests()
|
public ListRepositoryTests()
|
||||||
{
|
{
|
||||||
_ctx = _db.CreateContext();
|
_ctx = _db.CreateContext();
|
||||||
_lists = new ListRepository(_ctx);
|
_lists = new ListRepository(_ctx);
|
||||||
_tags = new TagRepository(_ctx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -95,20 +93,4 @@ public sealed class ListRepositoryTests : IDisposable
|
|||||||
Assert.True(all.Count >= 2);
|
Assert.True(all.Count >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task TagJunction_AddAndRemove()
|
|
||||||
{
|
|
||||||
var listId = Guid.NewGuid().ToString();
|
|
||||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "Tagged", CreatedAt = DateTime.UtcNow });
|
|
||||||
var tagId = await _tags.GetOrCreateAsync("agent");
|
|
||||||
|
|
||||||
await _lists.AddTagAsync(listId, tagId);
|
|
||||||
var tags = await _lists.GetTagsAsync(listId);
|
|
||||||
Assert.Single(tags);
|
|
||||||
Assert.Equal("agent", tags[0].Name);
|
|
||||||
|
|
||||||
await _lists.RemoveTagAsync(listId, tagId);
|
|
||||||
tags = await _lists.GetTagsAsync(listId);
|
|
||||||
Assert.Empty(tags);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the invariant that no task may have <c>ParentTaskId</c> pointing to a
|
||||||
|
/// parent without <c>PlanningPhase.Active|Finalized</c>. Tests the three guard
|
||||||
|
/// rails: <c>CreateChildAsync</c> validation, <c>DiscardPlanningAsync</c>
|
||||||
|
/// gating with the optional dequeue path, and the startup repair sweep.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TaskRepositoryOrphanGuardTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
|
||||||
|
public TaskRepositoryOrphanGuardTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateListAsync()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Idle, PlanningPhase phase = PlanningPhase.None) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "T",
|
||||||
|
Status = status,
|
||||||
|
PlanningPhase = phase,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<TaskEntity> SeedPlanningParentAsync(string listId)
|
||||||
|
{
|
||||||
|
var parent = MakeTask(listId, status: TaskStatus.Idle, phase: PlanningPhase.Active);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CreateChildAsync validation ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildAsync_Throws_When_Parent_Has_No_Planning_Phase()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, phase: PlanningPhase.None);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => _tasks.CreateChildAsync(parent.Id, "child", null, null));
|
||||||
|
Assert.Contains("not in a planning phase", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildAsync_Succeeds_When_Parent_Is_Active()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
|
||||||
|
var child = await _tasks.CreateChildAsync(parent.Id, "child", null, null);
|
||||||
|
Assert.Equal(parent.Id, child.ParentTaskId);
|
||||||
|
Assert.Equal(TaskStatus.Idle, child.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DiscardPlanningAsync gating ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanning_NotInPlanning_When_Parent_Phase_Is_None()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var stray = MakeTask(listId, phase: PlanningPhase.None);
|
||||||
|
await _tasks.AddAsync(stray);
|
||||||
|
|
||||||
|
var outcome = await _tasks.DiscardPlanningAsync(stray.Id, dequeueQueuedChildren: false);
|
||||||
|
Assert.Equal(DiscardPlanningResult.NotInPlanning, outcome.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanning_Succeeds_When_All_Children_Are_Idle()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "a", null, null);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "b", null, null);
|
||||||
|
|
||||||
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
|
||||||
|
|
||||||
|
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
|
||||||
|
Assert.Equal(0, _ctx.Tasks.AsNoTracking().Count(t => t.ParentTaskId == parent.Id));
|
||||||
|
var reloaded = _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id);
|
||||||
|
Assert.Equal(PlanningPhase.None, reloaded.PlanningPhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanning_Blocks_On_Queued_Children_Without_Optin()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
|
await SetChildStatusAsync(child.Id, TaskStatus.Queued);
|
||||||
|
|
||||||
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
|
||||||
|
|
||||||
|
Assert.Equal(DiscardPlanningResult.BlockedByQueuedChildren, outcome.Result);
|
||||||
|
Assert.Equal(1, outcome.QueuedChildrenCount);
|
||||||
|
// Parent and child are untouched.
|
||||||
|
Assert.Equal(PlanningPhase.Active, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase);
|
||||||
|
Assert.Equal(TaskStatus.Queued, _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanning_With_Dequeue_Succeeds_And_Drops_Idle_Children()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
|
await SetChildStatusAsync(child.Id, TaskStatus.Queued);
|
||||||
|
|
||||||
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: true);
|
||||||
|
|
||||||
|
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
|
||||||
|
// Child was dequeued to Idle and then deleted as part of the discard.
|
||||||
|
Assert.False(_ctx.Tasks.AsNoTracking().Any(t => t.Id == child.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanning_Blocks_On_Running_Children_Even_With_Dequeue_Optin()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
|
await SetChildStatusAsync(child.Id, TaskStatus.Running);
|
||||||
|
|
||||||
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: true);
|
||||||
|
|
||||||
|
Assert.Equal(DiscardPlanningResult.BlockedByRunningChildren, outcome.Result);
|
||||||
|
Assert.Equal(1, outcome.RunningChildrenCount);
|
||||||
|
Assert.Equal(PlanningPhase.Active, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanning_Leaves_Terminal_Children_Attached()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
var done = await _tasks.CreateChildAsync(parent.Id, "done", null, null);
|
||||||
|
var failed = await _tasks.CreateChildAsync(parent.Id, "failed", null, null);
|
||||||
|
await SetChildStatusAsync(done.Id, TaskStatus.Done);
|
||||||
|
await SetChildStatusAsync(failed.Id, TaskStatus.Failed);
|
||||||
|
|
||||||
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
|
||||||
|
|
||||||
|
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
|
||||||
|
Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == done.Id).ParentTaskId);
|
||||||
|
Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == failed.Id).ParentTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dequeue sweep ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dequeue_Dequeues_Queued_Child_When_Parent_Is_Not_Planning()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
// Parent is plain (not planning), child attached -> stuck queued.
|
||||||
|
var parent = MakeTask(listId, phase: PlanningPhase.None);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
var predecessor = MakeTask(listId, status: TaskStatus.Idle);
|
||||||
|
await _tasks.AddAsync(predecessor);
|
||||||
|
var child = MakeTask(listId, status: TaskStatus.Queued);
|
||||||
|
child.ParentTaskId = parent.Id;
|
||||||
|
child.BlockedByTaskId = predecessor.Id;
|
||||||
|
await _tasks.AddAsync(child);
|
||||||
|
|
||||||
|
var dequeued = await _tasks.DequeueOrphanedChildrenAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, dequeued);
|
||||||
|
var reloaded = _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id);
|
||||||
|
Assert.Equal(TaskStatus.Idle, reloaded.Status);
|
||||||
|
Assert.Null(reloaded.BlockedByTaskId);
|
||||||
|
Assert.Equal(parent.Id, reloaded.ParentTaskId); // lineage stays
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dequeue_Leaves_Idle_Children_Of_NonPlanning_Parent_Alone()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, phase: PlanningPhase.None);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
var child = MakeTask(listId, status: TaskStatus.Idle);
|
||||||
|
child.ParentTaskId = parent.Id;
|
||||||
|
await _tasks.AddAsync(child);
|
||||||
|
|
||||||
|
var dequeued = await _tasks.DequeueOrphanedChildrenAsync();
|
||||||
|
Assert.Equal(0, dequeued);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dequeue_Leaves_Valid_Children_Untouched()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
|
|
||||||
|
var dequeued = await _tasks.DequeueOrphanedChildrenAsync();
|
||||||
|
Assert.Equal(0, dequeued);
|
||||||
|
Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id).ParentTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Planning lineage restoration ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RestoreLineage_ReAttaches_Unambiguous_Chain_And_Dequeues_Queued_Members()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
// Parent that once had a planning session but lost the link.
|
||||||
|
var parent = MakeTask(listId, phase: PlanningPhase.None);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
|
||||||
|
// Chain: head (idle, no blocked_by, someone is blocked by it) + 2 queued successors.
|
||||||
|
var head = MakeTask(listId, status: TaskStatus.Idle);
|
||||||
|
head.BlockedByTaskId = null;
|
||||||
|
await _tasks.AddAsync(head);
|
||||||
|
|
||||||
|
var mid = MakeTask(listId, status: TaskStatus.Queued);
|
||||||
|
mid.BlockedByTaskId = head.Id;
|
||||||
|
await _tasks.AddAsync(mid);
|
||||||
|
|
||||||
|
var tail = MakeTask(listId, status: TaskStatus.Queued);
|
||||||
|
tail.BlockedByTaskId = mid.Id;
|
||||||
|
await _tasks.AddAsync(tail);
|
||||||
|
|
||||||
|
var restored = await _tasks.RestorePlanningLineageAsync(parent.Id);
|
||||||
|
|
||||||
|
Assert.Equal(3, restored);
|
||||||
|
Assert.Equal(PlanningPhase.Finalized, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase);
|
||||||
|
Assert.All(new[] { head.Id, mid.Id, tail.Id }, id =>
|
||||||
|
Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == id).ParentTaskId));
|
||||||
|
Assert.Equal(TaskStatus.Idle, _ctx.Tasks.AsNoTracking().Single(t => t.Id == mid.Id).Status);
|
||||||
|
Assert.Equal(TaskStatus.Idle, _ctx.Tasks.AsNoTracking().Single(t => t.Id == tail.Id).Status);
|
||||||
|
// blocked_by intact for chain order.
|
||||||
|
Assert.Equal(head.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == mid.Id).BlockedByTaskId);
|
||||||
|
Assert.Equal(mid.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == tail.Id).BlockedByTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RestoreLineage_Skips_When_Multiple_Chains_Exist()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, phase: PlanningPhase.None);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
|
||||||
|
// Two independent chains in the same list -> ambiguous.
|
||||||
|
var headA = MakeTask(listId, status: TaskStatus.Idle); await _tasks.AddAsync(headA);
|
||||||
|
var midA = MakeTask(listId, status: TaskStatus.Queued); midA.BlockedByTaskId = headA.Id; await _tasks.AddAsync(midA);
|
||||||
|
var headB = MakeTask(listId, status: TaskStatus.Idle); await _tasks.AddAsync(headB);
|
||||||
|
var midB = MakeTask(listId, status: TaskStatus.Queued); midB.BlockedByTaskId = headB.Id; await _tasks.AddAsync(midB);
|
||||||
|
|
||||||
|
var restored = await _tasks.RestorePlanningLineageAsync(parent.Id);
|
||||||
|
Assert.Equal(0, restored);
|
||||||
|
Assert.Equal(PlanningPhase.None, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RestoreLineage_Skips_When_Parent_Already_Has_Planning_Phase()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = await SeedPlanningParentAsync(listId);
|
||||||
|
|
||||||
|
var restored = await _tasks.RestorePlanningLineageAsync(parent.Id);
|
||||||
|
Assert.Equal(0, restored);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetChildStatusAsync(string id, TaskStatus status)
|
||||||
|
{
|
||||||
|
var t = await _ctx.Tasks.FindAsync(id) ?? throw new InvalidOperationException();
|
||||||
|
t.Status = status;
|
||||||
|
await _ctx.SaveChangesAsync();
|
||||||
|
_ctx.Entry(t).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,14 +12,12 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
|||||||
private readonly ClaudeDoDbContext _ctx;
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly TaskRepository _tasks;
|
private readonly TaskRepository _tasks;
|
||||||
private readonly ListRepository _lists;
|
private readonly ListRepository _lists;
|
||||||
private readonly TagRepository _tags;
|
|
||||||
|
|
||||||
public TaskRepositoryPlanningTests()
|
public TaskRepositoryPlanningTests()
|
||||||
{
|
{
|
||||||
_ctx = _db.CreateContext();
|
_ctx = _db.CreateContext();
|
||||||
_tasks = new TaskRepository(_ctx);
|
_tasks = new TaskRepository(_ctx);
|
||||||
_lists = new ListRepository(_ctx);
|
_lists = new ListRepository(_ctx);
|
||||||
_tags = new TagRepository(_ctx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -97,7 +95,6 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
|||||||
parent.Id,
|
parent.Id,
|
||||||
title: "child title",
|
title: "child title",
|
||||||
description: "child desc",
|
description: "child desc",
|
||||||
tagNames: new[] { "agent" },
|
|
||||||
commitType: "feat");
|
commitType: "feat");
|
||||||
|
|
||||||
Assert.Equal(TaskStatus.Idle, child.Status);
|
Assert.Equal(TaskStatus.Idle, child.Status);
|
||||||
@@ -110,9 +107,6 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
|||||||
var loaded = await _tasks.GetByIdAsync(child.Id);
|
var loaded = await _tasks.GetByIdAsync(child.Id);
|
||||||
Assert.NotNull(loaded);
|
Assert.NotNull(loaded);
|
||||||
Assert.Equal(TaskStatus.Idle, loaded!.Status);
|
Assert.Equal(TaskStatus.Idle, loaded!.Status);
|
||||||
|
|
||||||
var tags = await _tasks.GetTagsAsync(child.Id);
|
|
||||||
Assert.Contains(tags, t => t.Name == "agent");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -122,7 +116,7 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
|||||||
_ = listId;
|
_ = listId;
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
_tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null, null));
|
_tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -202,12 +196,12 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
|||||||
await _tasks.AddAsync(parent);
|
await _tasks.AddAsync(parent);
|
||||||
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||||
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42");
|
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42");
|
||||||
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
|
||||||
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
|
||||||
|
|
||||||
var ok = await _tasks.DiscardPlanningAsync(parent.Id);
|
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
|
||||||
|
|
||||||
Assert.True(ok);
|
Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result);
|
||||||
Assert.Null(await _tasks.GetByIdAsync(c1.Id));
|
Assert.Null(await _tasks.GetByIdAsync(c1.Id));
|
||||||
Assert.Null(await _tasks.GetByIdAsync(c2.Id));
|
Assert.Null(await _tasks.GetByIdAsync(c2.Id));
|
||||||
|
|
||||||
@@ -226,9 +220,9 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
|||||||
var task = MakeTask(listId);
|
var task = MakeTask(listId);
|
||||||
await _tasks.AddAsync(task);
|
await _tasks.AddAsync(task);
|
||||||
|
|
||||||
var ok = await _tasks.DiscardPlanningAsync(task.Id);
|
var outcome = await _tasks.DiscardPlanningAsync(task.Id, dequeueQueuedChildren: false);
|
||||||
|
|
||||||
Assert.False(ok);
|
Assert.Equal(DiscardPlanningResult.NotInPlanning, outcome.Result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -237,7 +231,7 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
|||||||
var listId = await CreateListAsync();
|
var listId = await CreateListAsync();
|
||||||
var parent = MakeTask(listId, phase: PlanningPhase.Active);
|
var parent = MakeTask(listId, phase: PlanningPhase.Active);
|
||||||
await _tasks.AddAsync(parent);
|
await _tasks.AddAsync(parent);
|
||||||
await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<Microsoft.Data.Sqlite.SqliteException>(async () =>
|
await Assert.ThrowsAsync<Microsoft.Data.Sqlite.SqliteException>(async () =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,14 +12,12 @@ public sealed class TaskRepositoryTests : IDisposable
|
|||||||
private readonly ClaudeDoDbContext _ctx;
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly TaskRepository _tasks;
|
private readonly TaskRepository _tasks;
|
||||||
private readonly ListRepository _lists;
|
private readonly ListRepository _lists;
|
||||||
private readonly TagRepository _tags;
|
|
||||||
|
|
||||||
public TaskRepositoryTests()
|
public TaskRepositoryTests()
|
||||||
{
|
{
|
||||||
_ctx = _db.CreateContext();
|
_ctx = _db.CreateContext();
|
||||||
_tasks = new TaskRepository(_ctx);
|
_tasks = new TaskRepository(_ctx);
|
||||||
_lists = new ListRepository(_ctx);
|
_lists = new ListRepository(_ctx);
|
||||||
_tags = new TagRepository(_ctx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -239,83 +237,4 @@ public sealed class TaskRepositoryTests : IDisposable
|
|||||||
Assert.Equal(0, reloadB!.SortOrder);
|
Assert.Equal(0, reloadB!.SortOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags()
|
|
||||||
{
|
|
||||||
var listId = await CreateListAsync();
|
|
||||||
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
|
||||||
var manualTagId = await _tags.GetOrCreateAsync("manual");
|
|
||||||
var codeTagId = await _tags.GetOrCreateAsync("code");
|
|
||||||
|
|
||||||
await _lists.AddTagAsync(listId, agentTagId);
|
|
||||||
|
|
||||||
var task = MakeTask(listId);
|
|
||||||
await _tasks.AddAsync(task);
|
|
||||||
await _tasks.AddTagAsync(task.Id, manualTagId);
|
|
||||||
await _tasks.AddTagAsync(task.Id, codeTagId);
|
|
||||||
|
|
||||||
var effective = await _tasks.GetEffectiveTagsAsync(task.Id);
|
|
||||||
var names = effective.Select(t => t.Name).OrderBy(n => n).ToList();
|
|
||||||
|
|
||||||
Assert.Equal(3, names.Count);
|
|
||||||
Assert.Contains("agent", names);
|
|
||||||
Assert.Contains("code", names);
|
|
||||||
Assert.Contains("manual", names);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SetTagsAsync_AttachesNewTagsAndCreatesMissingRows()
|
|
||||||
{
|
|
||||||
var listId = await CreateListAsync("L");
|
|
||||||
var task = MakeTask(listId);
|
|
||||||
await _tasks.AddAsync(task);
|
|
||||||
|
|
||||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "novel-tag" });
|
|
||||||
|
|
||||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
|
||||||
Assert.Contains(tags, t => t.Name == "agent");
|
|
||||||
Assert.Contains(tags, t => t.Name == "novel-tag");
|
|
||||||
Assert.Equal(2, tags.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SetTagsAsync_ReplacesExistingTagSet()
|
|
||||||
{
|
|
||||||
var listId = await CreateListAsync("L");
|
|
||||||
var task = MakeTask(listId);
|
|
||||||
await _tasks.AddAsync(task);
|
|
||||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
|
||||||
|
|
||||||
await _tasks.SetTagsAsync(task.Id, new[] { "manual" });
|
|
||||||
|
|
||||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
|
||||||
Assert.Single(tags);
|
|
||||||
Assert.Equal("manual", tags[0].Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SetTagsAsync_DeduplicatesCaseInsensitively()
|
|
||||||
{
|
|
||||||
var listId = await CreateListAsync("L");
|
|
||||||
var task = MakeTask(listId);
|
|
||||||
await _tasks.AddAsync(task);
|
|
||||||
|
|
||||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "AGENT", "Agent" });
|
|
||||||
|
|
||||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
|
||||||
Assert.Single(tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SetTagsAsync_EmptyListClearsAllTags()
|
|
||||||
{
|
|
||||||
var listId = await CreateListAsync("L");
|
|
||||||
var task = MakeTask(listId);
|
|
||||||
await _tasks.AddAsync(task);
|
|
||||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
|
||||||
|
|
||||||
await _tasks.SetTagsAsync(task.Id, Array.Empty<string>());
|
|
||||||
|
|
||||||
Assert.Empty(await _tasks.GetTagsAsync(task.Id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
|
|||||||
private readonly ClaudeDoDbContext _ctx;
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly TaskRepository _taskRepo;
|
private readonly TaskRepository _taskRepo;
|
||||||
private readonly ListRepository _listRepo;
|
private readonly ListRepository _listRepo;
|
||||||
private readonly TagRepository _tagRepo;
|
|
||||||
private readonly WorkerConfig _cfg;
|
private readonly WorkerConfig _cfg;
|
||||||
private readonly string _tempDir;
|
private readonly string _tempDir;
|
||||||
|
|
||||||
@@ -27,7 +26,6 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
|
|||||||
_ctx = _db.CreateContext();
|
_ctx = _db.CreateContext();
|
||||||
_taskRepo = new TaskRepository(_ctx);
|
_taskRepo = new TaskRepository(_ctx);
|
||||||
_listRepo = new ListRepository(_ctx);
|
_listRepo = new ListRepository(_ctx);
|
||||||
_tagRepo = new TagRepository(_ctx);
|
|
||||||
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_slotguard_{Guid.NewGuid():N}");
|
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_slotguard_{Guid.NewGuid():N}");
|
||||||
Directory.CreateDirectory(_tempDir);
|
Directory.CreateDirectory(_tempDir);
|
||||||
_cfg = new WorkerConfig
|
_cfg = new WorkerConfig
|
||||||
@@ -68,9 +66,6 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
|
|||||||
{
|
{
|
||||||
var listId = Guid.NewGuid().ToString();
|
var listId = Guid.NewGuid().ToString();
|
||||||
await _listRepo.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow });
|
await _listRepo.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow });
|
||||||
var tags = await _tagRepo.GetAllAsync();
|
|
||||||
var agentTag = tags.First(t => t.Name == "agent");
|
|
||||||
await _listRepo.AddTagAsync(listId, agentTag.Id);
|
|
||||||
return listId;
|
return listId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
private readonly ClaudeDoDbContext _ctx;
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly TaskRepository _taskRepo;
|
private readonly TaskRepository _taskRepo;
|
||||||
private readonly ListRepository _listRepo;
|
private readonly ListRepository _listRepo;
|
||||||
private readonly TagRepository _tagRepo;
|
|
||||||
private readonly WorkerConfig _cfg;
|
private readonly WorkerConfig _cfg;
|
||||||
private readonly string _tempDir;
|
private readonly string _tempDir;
|
||||||
|
|
||||||
@@ -28,7 +27,6 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
_ctx = _db.CreateContext();
|
_ctx = _db.CreateContext();
|
||||||
_taskRepo = new TaskRepository(_ctx);
|
_taskRepo = new TaskRepository(_ctx);
|
||||||
_listRepo = new ListRepository(_ctx);
|
_listRepo = new ListRepository(_ctx);
|
||||||
_tagRepo = new TagRepository(_ctx);
|
|
||||||
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}");
|
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}");
|
||||||
Directory.CreateDirectory(_tempDir);
|
Directory.CreateDirectory(_tempDir);
|
||||||
_cfg = new WorkerConfig
|
_cfg = new WorkerConfig
|
||||||
@@ -69,11 +67,7 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
var listId = Guid.NewGuid().ToString();
|
var listId = Guid.NewGuid().ToString();
|
||||||
await _listRepo.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow });
|
await _listRepo.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow });
|
||||||
|
return (listId, 0L);
|
||||||
var tags = await _tagRepo.GetAllAsync();
|
|
||||||
var agentTag = tags.First(t => t.Name == "agent");
|
|
||||||
await _listRepo.AddTagAsync(listId, agentTag.Id);
|
|
||||||
return (listId, agentTag.Id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<TaskEntity> SeedQueuedTask(string listId, DateTime? scheduledFor = null, DateTime? createdAt = null)
|
private async Task<TaskEntity> SeedQueuedTask(string listId, DateTime? scheduledFor = null, DateTime? createdAt = null)
|
||||||
|
|||||||
@@ -200,4 +200,281 @@ public class WorktreeMaintenanceServiceTests : IDisposable
|
|||||||
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
|
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
|
||||||
Assert.Empty(remaining);
|
Assert.Empty(remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CleanupFinished_With_ListId_Only_Removes_That_Lists_Rows()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var git = new GitService();
|
||||||
|
var db = NewDb();
|
||||||
|
|
||||||
|
var (listA, taskA) = MakeEntities(repo.RepoDir);
|
||||||
|
var (listB, taskB) = MakeEntities(repo.RepoDir);
|
||||||
|
|
||||||
|
var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id);
|
||||||
|
var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id);
|
||||||
|
|
||||||
|
using (var ctx = db.CreateContext())
|
||||||
|
{
|
||||||
|
await new ListRepository(ctx).AddAsync(listA);
|
||||||
|
await new ListRepository(ctx).AddAsync(listB);
|
||||||
|
var taskRepo = new TaskRepository(ctx);
|
||||||
|
await taskRepo.AddAsync(taskA);
|
||||||
|
await taskRepo.AddAsync(taskB);
|
||||||
|
var wtRepo = new WorktreeRepository(ctx);
|
||||||
|
await wtRepo.AddAsync(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}",
|
||||||
|
BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await wtRepo.AddAsync(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}",
|
||||||
|
BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var svc = new WorktreeMaintenanceService(
|
||||||
|
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
|
|
||||||
|
var result = await svc.CleanupFinishedAsync(listA.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Removed);
|
||||||
|
Assert.False(Directory.Exists(wtA));
|
||||||
|
Assert.True(Directory.Exists(wtB));
|
||||||
|
|
||||||
|
using var checkCtx = db.CreateContext();
|
||||||
|
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
|
||||||
|
Assert.Single(remaining);
|
||||||
|
Assert.Equal(taskB.Id, remaining[0].TaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOverview_Returns_All_When_ListId_Null()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var git = new GitService();
|
||||||
|
var db = NewDb();
|
||||||
|
|
||||||
|
var (listA, taskA) = MakeEntities(repo.RepoDir);
|
||||||
|
var (listB, taskB) = MakeEntities(repo.RepoDir);
|
||||||
|
var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id);
|
||||||
|
var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id);
|
||||||
|
|
||||||
|
using (var ctx = db.CreateContext())
|
||||||
|
{
|
||||||
|
await new ListRepository(ctx).AddAsync(listA);
|
||||||
|
await new ListRepository(ctx).AddAsync(listB);
|
||||||
|
await new TaskRepository(ctx).AddAsync(taskA);
|
||||||
|
await new TaskRepository(ctx).AddAsync(taskB);
|
||||||
|
var wtRepo = new WorktreeRepository(ctx);
|
||||||
|
await wtRepo.AddAsync(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}",
|
||||||
|
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await wtRepo.AddAsync(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}",
|
||||||
|
BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var svc = new WorktreeMaintenanceService(
|
||||||
|
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
|
|
||||||
|
var rows = await svc.GetOverviewAsync(null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, rows.Count);
|
||||||
|
Assert.Contains(rows, r => r.TaskId == taskA.Id && r.PathExistsOnDisk);
|
||||||
|
Assert.Contains(rows, r => r.TaskId == taskB.Id && r.PathExistsOnDisk);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOverview_Filters_By_ListId()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var git = new GitService();
|
||||||
|
var db = NewDb();
|
||||||
|
|
||||||
|
var (listA, taskA) = MakeEntities(repo.RepoDir);
|
||||||
|
var (listB, taskB) = MakeEntities(repo.RepoDir);
|
||||||
|
var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id);
|
||||||
|
var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id);
|
||||||
|
|
||||||
|
using (var ctx = db.CreateContext())
|
||||||
|
{
|
||||||
|
await new ListRepository(ctx).AddAsync(listA);
|
||||||
|
await new ListRepository(ctx).AddAsync(listB);
|
||||||
|
await new TaskRepository(ctx).AddAsync(taskA);
|
||||||
|
await new TaskRepository(ctx).AddAsync(taskB);
|
||||||
|
var wtRepo = new WorktreeRepository(ctx);
|
||||||
|
await wtRepo.AddAsync(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}",
|
||||||
|
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await wtRepo.AddAsync(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}",
|
||||||
|
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var svc = new WorktreeMaintenanceService(
|
||||||
|
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
|
|
||||||
|
var rows = await svc.GetOverviewAsync(listA.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(rows);
|
||||||
|
Assert.Equal(taskA.Id, rows[0].TaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOverview_Flags_PathExistsOnDisk_False_For_Phantom_Row()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var git = new GitService();
|
||||||
|
var db = NewDb();
|
||||||
|
|
||||||
|
var (list, task) = MakeEntities(repo.RepoDir);
|
||||||
|
var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id);
|
||||||
|
|
||||||
|
try { await git.WorktreeRemoveAsync(repo.RepoDir, wt, force: true); } catch { }
|
||||||
|
if (Directory.Exists(wt)) Directory.Delete(wt, recursive: true);
|
||||||
|
|
||||||
|
using (var ctx = db.CreateContext())
|
||||||
|
{
|
||||||
|
await new ListRepository(ctx).AddAsync(list);
|
||||||
|
await new TaskRepository(ctx).AddAsync(task);
|
||||||
|
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}",
|
||||||
|
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var svc = new WorktreeMaintenanceService(
|
||||||
|
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
|
|
||||||
|
var rows = await svc.GetOverviewAsync(null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(rows);
|
||||||
|
Assert.False(rows[0].PathExistsOnDisk);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForceRemove_Removes_Active_Worktree()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var git = new GitService();
|
||||||
|
var db = NewDb();
|
||||||
|
|
||||||
|
var (list, task) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Done);
|
||||||
|
var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id);
|
||||||
|
|
||||||
|
using (var ctx = db.CreateContext())
|
||||||
|
{
|
||||||
|
await new ListRepository(ctx).AddAsync(list);
|
||||||
|
await new TaskRepository(ctx).AddAsync(task);
|
||||||
|
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}",
|
||||||
|
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var svc = new WorktreeMaintenanceService(
|
||||||
|
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
|
|
||||||
|
var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Removed);
|
||||||
|
Assert.Null(result.Reason);
|
||||||
|
Assert.False(Directory.Exists(wt));
|
||||||
|
|
||||||
|
using var checkCtx = db.CreateContext();
|
||||||
|
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
|
||||||
|
Assert.Empty(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForceRemove_Blocked_When_Task_Running()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var git = new GitService();
|
||||||
|
var db = NewDb();
|
||||||
|
|
||||||
|
var (list, task) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Running);
|
||||||
|
var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id);
|
||||||
|
|
||||||
|
using (var ctx = db.CreateContext())
|
||||||
|
{
|
||||||
|
await new ListRepository(ctx).AddAsync(list);
|
||||||
|
await new TaskRepository(ctx).AddAsync(task);
|
||||||
|
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}",
|
||||||
|
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var svc = new WorktreeMaintenanceService(
|
||||||
|
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
|
|
||||||
|
var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Removed);
|
||||||
|
Assert.Equal("task is currently running", result.Reason);
|
||||||
|
Assert.True(Directory.Exists(wt));
|
||||||
|
|
||||||
|
try { await git.WorktreeRemoveAsync(repo.RepoDir, wt, force: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForceRemove_Removes_Phantom_Row()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var git = new GitService();
|
||||||
|
var db = NewDb();
|
||||||
|
|
||||||
|
var (list, task) = MakeEntities(repo.RepoDir);
|
||||||
|
var phantomPath = Path.Combine(Path.GetTempPath(), $"wt_phantom_{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
using (var ctx = db.CreateContext())
|
||||||
|
{
|
||||||
|
await new ListRepository(ctx).AddAsync(list);
|
||||||
|
await new TaskRepository(ctx).AddAsync(task);
|
||||||
|
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = task.Id, Path = phantomPath, BranchName = $"test/{task.Id}-phantom",
|
||||||
|
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var svc = new WorktreeMaintenanceService(
|
||||||
|
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
|
|
||||||
|
var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Removed);
|
||||||
|
|
||||||
|
using var checkCtx = db.CreateContext();
|
||||||
|
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
|
||||||
|
Assert.Empty(remaining);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
@@ -24,6 +25,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
|||||||
public event Action<string, string, DateTime>? TaskStartedEvent;
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
||||||
@@ -38,14 +40,16 @@ sealed class FakeWorkerClient : IWorkerClient
|
|||||||
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||||
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
||||||
public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
|
public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
|
||||||
public Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames) => Task.CompletedTask;
|
|
||||||
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
|
||||||
public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; }
|
public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; }
|
||||||
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; }
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; }
|
||||||
public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; 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<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
DiscardPlanningCalls++;
|
||||||
|
return Task.FromResult(new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, 0, 0));
|
||||||
|
}
|
||||||
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { FinalizePlanningCalls++; 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);
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user