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" />
|
||||
</Folder>
|
||||
<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.Worker.Tests/ClaudeDo.Worker.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
|
||||
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.AddTransient<PrimeClaudeTabViewModel>();
|
||||
sc.AddTransient<SettingsModalViewModel>();
|
||||
|
||||
@@ -12,7 +12,6 @@ public class ClaudeDoDbContext : DbContext
|
||||
|
||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||
public DbSet<TagEntity> Tags => Set<TagEntity>();
|
||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||
|
||||
@@ -21,16 +21,5 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
|
||||
.WithOne(c => c.List)
|
||||
.HasForeignKey<ListConfigEntity>(c => c.ListId)
|
||||
.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)
|
||||
.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.Status).HasDatabaseName("idx_tasks_status");
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
|
||||
@@ -97,6 +106,15 @@ public sealed class GitService
|
||||
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)
|
||||
{
|
||||
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);
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -526,36 +494,6 @@ namespace ClaudeDo.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
@@ -623,36 +561,6 @@ namespace ClaudeDo.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
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 DateTime CreatedAt { get; init; }
|
||||
public string? WorkingDir { get; set; }
|
||||
public string DefaultCommitType { get; set; } = "chore";
|
||||
public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
|
||||
|
||||
// Navigation properties
|
||||
public ListConfigEntity? Config { get; set; }
|
||||
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 DateTime? StartedAt { 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? SystemPrompt { get; set; }
|
||||
public string? AgentPath { get; set; }
|
||||
@@ -51,7 +51,6 @@ public sealed class TaskEntity
|
||||
// Navigation properties
|
||||
public ListEntity List { get; set; } = null!;
|
||||
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<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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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
|
||||
|
||||
#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
|
||||
|
||||
public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default)
|
||||
@@ -254,13 +186,18 @@ public sealed class TaskRepository
|
||||
string parentId,
|
||||
string title,
|
||||
string? description,
|
||||
IReadOnlyList<string>? tagNames,
|
||||
string? commitType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||
// 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)
|
||||
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
|
||||
.Where(t => t.ListId == parent.ListId)
|
||||
@@ -280,22 +217,6 @@ public sealed class TaskRepository
|
||||
SortOrder = (maxSort ?? -1) + 1,
|
||||
};
|
||||
_context.Tasks.Add(child);
|
||||
|
||||
if (tagNames is not null && tagNames.Count > 0)
|
||||
{
|
||||
foreach (var tagName in tagNames.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == tagName, ct);
|
||||
if (tag is null)
|
||||
{
|
||||
tag = new TagEntity { Name = tagName };
|
||||
_context.Tags.Add(tag);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
child.Tags.Add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return child;
|
||||
}
|
||||
@@ -305,11 +226,10 @@ public sealed class TaskRepository
|
||||
string? title,
|
||||
string? description,
|
||||
string? commitType,
|
||||
IReadOnlyList<string>? tagNames,
|
||||
TaskStatus? status,
|
||||
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.");
|
||||
|
||||
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 (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);
|
||||
}
|
||||
|
||||
@@ -401,8 +306,9 @@ public sealed class TaskRepository
|
||||
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
||||
}
|
||||
|
||||
public async Task<bool> DiscardPlanningAsync(
|
||||
public async Task<DiscardPlanningOutcome> DiscardPlanningAsync(
|
||||
string parentId,
|
||||
bool dequeueQueuedChildren,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||||
@@ -413,10 +319,42 @@ public sealed class TaskRepository
|
||||
if (parent is null || parent.PlanningPhase != PlanningPhase.Active)
|
||||
{
|
||||
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
|
||||
.Where(t => t.ParentTaskId == parentId
|
||||
&& t.Status == TaskStatus.Idle
|
||||
@@ -433,7 +371,96 @@ public sealed class TaskRepository
|
||||
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), 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(
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
|
||||
</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 ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
@@ -11,6 +12,8 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
event Action<string, string, DateTime>? TaskStartedEvent;
|
||||
event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||
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, string>? TaskMessageEvent;
|
||||
|
||||
@@ -29,12 +32,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||
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 OpenInteractiveTerminalAsync(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<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
|
||||
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
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>? TaskMessageEvent;
|
||||
public event Action<string>? TaskUpdatedEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string>? RunNowRequestedEvent;
|
||||
public event Action<string>? ListUpdatedEvent;
|
||||
@@ -64,12 +67,17 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(signalRUrl)
|
||||
.WithAutomaticReconnect(new IndefiniteRetryPolicy())
|
||||
.AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
})
|
||||
.Build();
|
||||
|
||||
_hub.Reconnected += async _ =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
|
||||
await SeedActiveTasksAsync();
|
||||
Dispatcher.UIThread.Post(() => ConnectionRestoredEvent?.Invoke());
|
||||
};
|
||||
|
||||
_hub.Reconnecting += _ =>
|
||||
@@ -194,6 +202,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.StartAsync(ct);
|
||||
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
|
||||
await SeedActiveTasksAsync();
|
||||
Dispatcher.UIThread.Post(() => ConnectionRestoredEvent?.Invoke());
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -386,28 +395,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
||||
}
|
||||
|
||||
public async Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames)
|
||||
{
|
||||
await _hub.InvokeAsync("SetTaskTags", taskId, tagNames.ToArray());
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAllTagsAsync()
|
||||
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<List<string>>("GetAllTags") ?? new List<string>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees");
|
||||
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
|
||||
}
|
||||
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)
|
||||
=> 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)
|
||||
=> await _hub.InvokeAsync("OpenInteractiveTerminalAsync", taskId, ct);
|
||||
|
||||
public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
|
||||
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
|
||||
|
||||
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
|
||||
@@ -496,8 +525,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
=> await StartPlanningSessionAsync(taskId, ct);
|
||||
async Task IWorkerClient.ResumePlanningSessionAsync(string taskId, CancellationToken ct)
|
||||
=> await ResumePlanningSessionAsync(taskId, ct);
|
||||
async Task IWorkerClient.DiscardPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||
=> await DiscardPlanningSessionAsync(taskId, ct);
|
||||
async Task<DiscardPlanningOutcome> IWorkerClient.DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren, CancellationToken ct)
|
||||
=> await DiscardPlanningSessionAsync(taskId, dequeueQueuedChildren, ct);
|
||||
async Task IWorkerClient.FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
||||
=> await FinalizePlanningSessionAsync(taskId, queueAgentTasks, ct);
|
||||
async Task<int> IWorkerClient.GetPendingDraftCountAsync(string taskId, CancellationToken ct)
|
||||
@@ -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 ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
|
||||
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)
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
|
||||
private TaskRowViewModel? _task;
|
||||
|
||||
// Editable fields
|
||||
@@ -56,74 +58,23 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
// Short task-id badge, e.g. "#T1A"
|
||||
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]
|
||||
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ResetCommand))]
|
||||
private bool _showFailedActions;
|
||||
private string _agentStatusLabel = "Idle";
|
||||
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]
|
||||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||
@@ -131,16 +82,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
|
||||
partial void OnAgentStatusLabelChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsIdle));
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsFailed));
|
||||
OnPropertyChanged(nameof(IsCancelled));
|
||||
OnPropertyChanged(nameof(ShowContinue));
|
||||
OnPropertyChanged(nameof(ShowResetAndRetry));
|
||||
OnPropertyChanged(nameof(IsAgentSectionEnabled));
|
||||
ShowFailedActions = value == "Failed";
|
||||
}
|
||||
[ObservableProperty] private string? _model;
|
||||
|
||||
// Agent settings overrides
|
||||
[ObservableProperty] private string _taskModelSelection = "(inherit)";
|
||||
[ObservableProperty] private string _taskModelSelection = ModelRegistry.TaskInheritSentinel;
|
||||
[ObservableProperty] private string _taskSystemPrompt = "";
|
||||
[ObservableProperty] private AgentInfo? _taskSelectedAgent;
|
||||
|
||||
@@ -148,10 +103,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
||||
[ObservableProperty] private string _effectiveAgentHint = "";
|
||||
|
||||
public System.Collections.ObjectModel.ObservableCollection<string> TaskModelOptions { get; } = new()
|
||||
{
|
||||
"(inherit)", "sonnet", "opus", "haiku",
|
||||
};
|
||||
public System.Collections.ObjectModel.ObservableCollection<string> TaskModelOptions { get; } = new(
|
||||
new[] { ModelRegistry.TaskInheritSentinel }.Concat(ModelRegistry.Aliases));
|
||||
|
||||
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
|
||||
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
||||
|
||||
private void ApplyTagsFromEntity(ClaudeDo.Data.Models.TaskEntity entity)
|
||||
{
|
||||
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)
|
||||
private async System.Threading.Tasks.Task RefreshStatusAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Tags)
|
||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity is null || Task?.Id != taskId) return;
|
||||
|
||||
_suppressStatusSave = true;
|
||||
try { SelectedStatus = entity.Status; }
|
||||
finally { _suppressStatusSave = false; }
|
||||
AgentStatusLabel = entity.Status.ToString();
|
||||
ApplyTagsFromEntity(entity);
|
||||
await RefreshAvailableTagsAsync();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
@@ -289,9 +219,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
{
|
||||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||||
{
|
||||
RunNowCommand.NotifyCanExecuteChanged();
|
||||
EnqueueCommand.NotifyCanExecuteChanged();
|
||||
DequeueCommand.NotifyCanExecuteChanged();
|
||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||
ContinueCommand.NotifyCanExecuteChanged();
|
||||
ResetCommand.NotifyCanExecuteChanged();
|
||||
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
};
|
||||
@@ -323,7 +254,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
|
||||
_worker.TaskUpdatedEvent += taskId =>
|
||||
{
|
||||
if (Task?.Id == taskId) _ = RefreshTagsAndStatusAsync(taskId);
|
||||
if (Task?.Id == taskId) _ = RefreshStatusAsync(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);
|
||||
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 ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
|
||||
? null : TaskSelectedAgent.Path;
|
||||
@@ -451,11 +382,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
try
|
||||
{
|
||||
TaskAgentOptions.Clear();
|
||||
TaskAgentOptions.Add(new AgentInfo("(inherit)", "", ""));
|
||||
TaskAgentOptions.Add(new AgentInfo(ModelRegistry.TaskInheritSentinel, "", ""));
|
||||
var agents = await _worker.GetAgentsAsync();
|
||||
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 ?? "";
|
||||
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
|
||||
? TaskAgentOptions[0]
|
||||
@@ -503,17 +434,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
BranchLine = null;
|
||||
AgentStatusLabel = "Idle";
|
||||
LatestRunSessionId = null;
|
||||
ShowFailedActions = false;
|
||||
Tags.Clear();
|
||||
AvailableTags.Clear();
|
||||
NewTagInput = "";
|
||||
_suppressStatusSave = true;
|
||||
try { SelectedStatus = ClaudeDo.Data.Models.TaskStatus.Idle; }
|
||||
finally { _suppressStatusSave = false; }
|
||||
_suppressAgentSave = true;
|
||||
try
|
||||
{
|
||||
TaskModelSelection = "(inherit)";
|
||||
TaskModelSelection = ModelRegistry.TaskInheritSentinel;
|
||||
TaskSystemPrompt = "";
|
||||
TaskSelectedAgent = null;
|
||||
}
|
||||
@@ -537,11 +461,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
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
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Worktree)
|
||||
.Include(t => t.Tags)
|
||||
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (entity == null) return;
|
||||
@@ -557,11 +480,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||||
AgentStatusLabel = entity.Status.ToString();
|
||||
_suppressStatusSave = true;
|
||||
try { SelectedStatus = entity.Status; }
|
||||
finally { _suppressStatusSave = false; }
|
||||
ApplyTagsFromEntity(entity);
|
||||
await RefreshAvailableTagsAsync();
|
||||
await LoadAgentSettingsAsync(entity, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -926,24 +844,35 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
await _worker.CancelTaskAsync(Task.Id);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRunNow))]
|
||||
private async System.Threading.Tasks.Task RunNowAsync()
|
||||
[RelayCommand(CanExecute = nameof(CanEnqueue))]
|
||||
private async System.Threading.Tasks.Task EnqueueAsync()
|
||||
{
|
||||
if (Task == null) return;
|
||||
AgentStatusLabel = "Running";
|
||||
try
|
||||
{
|
||||
await _worker.RunNowAsync(Task.Id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
AgentStatusLabel = "Failed";
|
||||
throw;
|
||||
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||
AgentStatusLabel = "Queued";
|
||||
}
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
private bool CanRunNow() =>
|
||||
Task != null && _worker.IsConnected && !IsRunning;
|
||||
private bool CanEnqueue() =>
|
||||
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))]
|
||||
private async System.Threading.Tasks.Task ContinueAsync()
|
||||
@@ -953,23 +882,32 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
private bool CanContinue() =>
|
||||
Task != null && _worker.IsConnected && ShowFailedActions && !string.IsNullOrEmpty(LatestRunSessionId);
|
||||
Task != null && _worker.IsConnected && ShowContinue && !string.IsNullOrEmpty(LatestRunSessionId);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanReset))]
|
||||
private async System.Threading.Tasks.Task ResetAsync()
|
||||
[RelayCommand(CanExecute = nameof(CanResetAndRetry))]
|
||||
private async System.Threading.Tasks.Task ResetAndRetryAsync()
|
||||
{
|
||||
if (Task == null) return;
|
||||
if (ConfirmAsync == null) return;
|
||||
|
||||
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;
|
||||
|
||||
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() =>
|
||||
Task != null && _worker.IsConnected && ShowFailedActions;
|
||||
private bool CanResetAndRetry() =>
|
||||
Task != null && _worker.IsConnected && ShowResetAndRetry;
|
||||
}
|
||||
|
||||
public sealed partial class SubtaskRowViewModel : ViewModelBase
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
@@ -10,7 +11,7 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
||||
[ObservableProperty] private int _count;
|
||||
[ObservableProperty] private bool _isActive;
|
||||
[ObservableProperty] private string? _workingDir;
|
||||
[ObservableProperty] private string _defaultCommitType = "chore";
|
||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||
public string? IconKey { get; init; }
|
||||
public string? DotColorKey { get; init; }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Filtering;
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
@@ -19,6 +20,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IServiceProvider? _services;
|
||||
private readonly WorkerClient? _worker;
|
||||
private static readonly TaskListFilterRegistry _filters = new();
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
public event EventHandler? FocusSearchRequested;
|
||||
@@ -26,6 +28,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
|
||||
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
|
||||
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
|
||||
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenSettings()
|
||||
@@ -47,6 +50,18 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
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> SmartLists { 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.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = 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);
|
||||
|
||||
// Snapshot the open (non-Done) tasks once; small enough collection for client-side grouping.
|
||||
var open = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Status != TaskStatus.Done)
|
||||
.Select(t => new { t.ListId, t.Status, t.IsMyDay, t.IsStarred, Scheduled = t.ScheduledFor })
|
||||
// Single snapshot; counters and the list loader share the same filter strategies.
|
||||
var all = await ctx.Tasks.AsNoTracking()
|
||||
.Include(t => t.Worktree)
|
||||
.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)
|
||||
{
|
||||
item.Count = item.Id switch
|
||||
{
|
||||
"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,
|
||||
};
|
||||
var filter = _filters.Resolve(item.Id);
|
||||
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
|
||||
}
|
||||
|
||||
foreach (var item in UserLists)
|
||||
{
|
||||
var listId = item.Id.StartsWith("user:", StringComparison.Ordinal)
|
||||
? item.Id["user:".Length..]
|
||||
: item.Id;
|
||||
item.Count = open.Count(t => t.ListId == listId);
|
||||
var filter = _filters.Resolve(item.Id);
|
||||
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
@@ -177,7 +176,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Name = "New list",
|
||||
DefaultCommitType = "chore",
|
||||
DefaultCommitType = CommitTypeRegistry.DefaultType,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
@@ -8,11 +6,6 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
{
|
||||
public TaskRowViewModel()
|
||||
{
|
||||
Tags.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTags));
|
||||
}
|
||||
|
||||
public required string Id { get; init; }
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string _listName = "";
|
||||
@@ -39,7 +32,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
||||
|
||||
public ObservableCollection<string> Tags { get; } = new();
|
||||
public int StepsCount { 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 HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
|
||||
public bool HasTags => Tags.Count > 0;
|
||||
public bool HasSteps => StepsCount > 0;
|
||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||
public bool IsRunning => Status == TaskStatus.Running;
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks;
|
||||
public bool HasSchedule => ScheduledFor.HasValue;
|
||||
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||
|
||||
@@ -96,6 +88,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
}
|
||||
|
||||
partial void OnPlanningPhaseChanged(PlanningPhase value)
|
||||
@@ -107,7 +100,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
partial void OnHasQueuedSubtasksChanged(bool value)
|
||||
=> OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
{
|
||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
}
|
||||
|
||||
partial void OnBlockedByTaskIdChanged(string? value)
|
||||
{
|
||||
@@ -160,15 +156,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
DiffDeletions = del;
|
||||
ParentTaskId = t.ParentTaskId;
|
||||
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".
|
||||
|
||||
@@ -3,7 +3,9 @@ using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Filtering;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -18,6 +20,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
private readonly Dictionary<string, bool> _expandedState = new();
|
||||
private ListNavItemViewModel? _currentList;
|
||||
private CancellationTokenSource? _loadCts;
|
||||
private static readonly TaskListFilterRegistry _filters = new();
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
public event EventHandler? FocusAddTaskRequested;
|
||||
@@ -28,7 +31,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
|
||||
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
|
||||
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
|
||||
public ObservableCollection<string> AllTags { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _newTaskTitle = "";
|
||||
[ObservableProperty] private TaskRowViewModel? _selectedTask;
|
||||
@@ -52,25 +54,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
_worker = worker;
|
||||
if (_worker is not null)
|
||||
{
|
||||
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
||||
_ = RefreshAllTagsAsync();
|
||||
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
||||
_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)
|
||||
{
|
||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
||||
@@ -97,7 +87,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
var entity = await db.Tasks
|
||||
.Include(t => t.List)
|
||||
.Include(t => t.Worktree)
|
||||
.Include(t => t.Tags)
|
||||
.FirstOrDefaultAsync(t => t.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
|
||||
.Include(t => t.List)
|
||||
.Include(t => t.Worktree)
|
||||
.Include(t => t.Tags)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
static bool IsPlanningParent(TaskEntity t) => t.PlanningPhase != PlanningPhase.None;
|
||||
|
||||
IEnumerable<TaskEntity> filtered = list.Kind switch
|
||||
{
|
||||
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 filter = _filters.Resolve(list.Id);
|
||||
var filteredList = filter is null
|
||||
? new List<TaskEntity>()
|
||||
: all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList();
|
||||
var topIds = filteredList.Where(t => t.ParentTaskId == null).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!)))
|
||||
@@ -282,17 +255,27 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
|
||||
// Build hierarchy-aware flat list: top-level rows interleaved with visible children.
|
||||
// Items is already ordered by SortOrder from the DB query.
|
||||
var topLevel = Items.Where(r => !r.IsChild);
|
||||
// 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 emitted = new HashSet<string>();
|
||||
foreach (var parent in topLevel)
|
||||
{
|
||||
if (!emitted.Add(parent.Id)) continue;
|
||||
flat.Add(parent);
|
||||
// Also expand for Done parents so their (Done) children reach the classification
|
||||
// loop and land in CompletedItems alongside the parent.
|
||||
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
|
||||
{
|
||||
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 */ }
|
||||
}
|
||||
|
||||
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]
|
||||
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.IsRunning) return;
|
||||
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;
|
||||
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();
|
||||
row.Status = TaskStatus.Queued;
|
||||
if (_worker is not null)
|
||||
@@ -569,6 +529,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
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)
|
||||
{
|
||||
if (row is null) return;
|
||||
@@ -650,7 +618,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
await _worker.FinalizePlanningSessionAsync(row.Id);
|
||||
break;
|
||||
case UnfinishedPlanningModalResult.Discard:
|
||||
await _worker.DiscardPlanningSessionAsync(row.Id);
|
||||
await TryDiscardPlanningWithRetryAsync(row.Id);
|
||||
break;
|
||||
case UnfinishedPlanningModalResult.Cancel:
|
||||
default:
|
||||
@@ -663,11 +631,46 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
[RelayCommand]
|
||||
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
try { await _worker!.DiscardPlanningSessionAsync(row.Id); }
|
||||
catch { }
|
||||
if (row is null || _worker is null) return;
|
||||
await TryDiscardPlanningWithRetryAsync(row.Id);
|
||||
}
|
||||
|
||||
/// <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]
|
||||
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
private readonly UpdateCheckService _updateCheck;
|
||||
private readonly InstallerLocator _installerLocator;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
||||
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
||||
|
||||
// Set by MainWindow to open the conflict resolution dialog.
|
||||
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.
|
||||
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 string? _updateBannerLatestVersion;
|
||||
[ObservableProperty] private string? _inlineUpdateStatus;
|
||||
@@ -159,12 +163,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
WorkerClient worker,
|
||||
UpdateCheckService updateCheck,
|
||||
InstallerLocator installerLocator,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory)
|
||||
{
|
||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||
_updateCheck = updateCheck;
|
||||
_installerLocator = installerLocator;
|
||||
_dbFactory = dbFactory;
|
||||
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
|
||||
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
||||
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||
@@ -249,12 +255,67 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
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]
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
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]
|
||||
private void DismissBanner()
|
||||
{
|
||||
|
||||
@@ -14,21 +14,16 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
|
||||
[ObservableProperty] private string _name = "";
|
||||
[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 AgentInfo? _selectedAgent;
|
||||
|
||||
public ObservableCollection<string> ModelOptions { get; } = new()
|
||||
{
|
||||
"(default)", "sonnet", "opus", "haiku",
|
||||
};
|
||||
public ObservableCollection<string> ModelOptions { get; } = new(
|
||||
new[] { ModelRegistry.ListDefaultSentinel }.Concat(ModelRegistry.Aliases));
|
||||
|
||||
public ObservableCollection<string> CommitTypeOptions { get; } = new()
|
||||
{
|
||||
"chore", "feat", "fix", "refactor", "docs", "test", "ci", "perf", "style", "build",
|
||||
};
|
||||
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
||||
|
||||
public ObservableCollection<AgentInfo> Agents { get; } = new();
|
||||
|
||||
@@ -49,7 +44,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
ListId = listId;
|
||||
Name = name;
|
||||
WorkingDir = workingDir ?? "";
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? "chore" : defaultCommitType;
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||
|
||||
Agents.Clear();
|
||||
Agents.Add(new AgentInfo("(none)", "", ""));
|
||||
@@ -57,7 +52,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
foreach (var a in agents) Agents.Add(a);
|
||||
|
||||
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 ?? "";
|
||||
SelectedAgent = string.IsNullOrWhiteSpace(config?.AgentPath)
|
||||
? Agents[0]
|
||||
@@ -67,7 +62,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
[RelayCommand]
|
||||
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 ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path;
|
||||
|
||||
@@ -89,7 +84,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
[RelayCommand]
|
||||
private void ResetAgentSettings()
|
||||
{
|
||||
SelectedModel = "(default)";
|
||||
SelectedModel = ModelRegistry.ListDefaultSentinel;
|
||||
SystemPrompt = "";
|
||||
SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
@@ -5,13 +6,12 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string _defaultClaudeInstructions = "";
|
||||
[ObservableProperty] private string _defaultModel = "sonnet";
|
||||
[ObservableProperty] private string _defaultModel = ModelRegistry.DefaultAlias;
|
||||
[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> PermissionModes { get; } = new[]
|
||||
{ "auto", "bypassPermissions", "acceptEdits", "plan", "default" };
|
||||
public IReadOnlyList<string> Models { get; } = ModelRegistry.Aliases;
|
||||
public IReadOnlyList<string> PermissionModes { get; } = PermissionModeRegistry.Modes;
|
||||
|
||||
public string? Validate()
|
||||
{
|
||||
|
||||
@@ -5,12 +5,22 @@ using ClaudeDo.Data.Git;
|
||||
|
||||
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 required string Name { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public bool IsDirectory { get; init; }
|
||||
public string RelativePath { get; init; } = "";
|
||||
public ObservableCollection<WorktreeNodeViewModel> Children { get; } = new();
|
||||
[ObservableProperty] private bool _isExpanded = true;
|
||||
}
|
||||
|
||||
public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
@@ -18,8 +28,11 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
private readonly GitService _git;
|
||||
|
||||
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
|
||||
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _worktreePath = "";
|
||||
[ObservableProperty] private string? _baseCommit;
|
||||
[ObservableProperty] private WorktreeNodeViewModel? _selectedNode;
|
||||
|
||||
// Set by the view (same pattern as DiffModalViewModel.CloseAction)
|
||||
public Action? CloseAction { get; set; }
|
||||
@@ -29,6 +42,43 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
_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]
|
||||
private void Close() => CloseAction?.Invoke();
|
||||
|
||||
@@ -37,7 +87,13 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
Root.Clear();
|
||||
|
||||
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; }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(stdout)) return;
|
||||
@@ -46,14 +102,27 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
|
||||
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)
|
||||
var xy = line[..2];
|
||||
// Pick staged char first, fall back to unstaged
|
||||
var statusChar = xy[0] != ' ' ? xy[0] : xy[1];
|
||||
var status = statusChar != ' ' ? statusChar.ToString() : null;
|
||||
var path = line[3..].Trim().Replace('\\', '/');
|
||||
if (committedMode)
|
||||
{
|
||||
// diff --name-status format: <status>\t<path>
|
||||
var tab = line.IndexOf('\t');
|
||||
if (tab < 0) continue;
|
||||
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);
|
||||
if (segments.Length == 0) continue;
|
||||
@@ -77,10 +146,24 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
{
|
||||
Name = segments[^1],
|
||||
Status = status,
|
||||
IsDirectory = false
|
||||
IsDirectory = false,
|
||||
RelativePath = path
|
||||
};
|
||||
if (parent == null) Root.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"
|
||||
Foreground="{DynamicResource BloodBrush}"/>
|
||||
</Button>
|
||||
<!-- Hand off button — only when idle -->
|
||||
<!-- Send to queue — only when idle -->
|
||||
<Button Grid.Column="3"
|
||||
Classes="btn accent"
|
||||
Content="Hand off"
|
||||
Command="{Binding RunNowCommand}"
|
||||
IsVisible="{Binding !IsRunning}"
|
||||
ToolTip.Tip="Hand task off to Claude"
|
||||
Content="Send to queue"
|
||||
Command="{Binding EnqueueCommand}"
|
||||
IsVisible="{Binding IsIdle}"
|
||||
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"
|
||||
Padding="10,4"/>
|
||||
</Grid>
|
||||
@@ -144,14 +153,14 @@
|
||||
<Button Classes="btn accent"
|
||||
Content="Continue"
|
||||
Command="{Binding ContinueCommand}"
|
||||
IsVisible="{Binding ShowFailedActions}"
|
||||
ToolTip.Tip="Resume the task from where it failed"
|
||||
IsVisible="{Binding ShowContinue}"
|
||||
ToolTip.Tip="Resume the last session and keep going"
|
||||
Padding="10,4"/>
|
||||
<Button Classes="btn"
|
||||
Content="Reset"
|
||||
Command="{Binding ResetCommand}"
|
||||
IsVisible="{Binding ShowFailedActions}"
|
||||
ToolTip.Tip="Discard the worktree and move the task back to Manual"
|
||||
Content="Reset & retry"
|
||||
Command="{Binding ResetAndRetryCommand}"
|
||||
IsVisible="{Binding ShowResetAndRetry}"
|
||||
ToolTip.Tip="Discard the worktree and re-queue the task to run from scratch"
|
||||
Padding="10,4"/>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -35,36 +35,39 @@
|
||||
</Grid>
|
||||
</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">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4">
|
||||
<Ellipse Width="5" Height="5" Fill="{DynamicResource AccentBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="eyebrow" Text="LOGBOOK" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding TaskIdBadge}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
Foreground="{DynamicResource TextFaintBrush}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<Ellipse Grid.Column="0"
|
||||
Classes="task-check"
|
||||
Classes.done="{Binding Task.Done}"
|
||||
Width="18" Height="18"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,2,10,0"
|
||||
Cursor="Hand"/>
|
||||
<StackPanel Grid.Column="1" Spacing="0">
|
||||
<TextBlock Text="{Binding TaskIdBadge}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
Foreground="{DynamicResource TextFaintBrush}"
|
||||
Margin="0,0,0,4"/>
|
||||
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
|
||||
FontSize="14" FontWeight="Medium"
|
||||
BorderThickness="0" Background="Transparent"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextWrapping="Wrap"
|
||||
AcceptsReturn="False"
|
||||
Padding="0"/>
|
||||
</StackPanel>
|
||||
|
||||
<ComboBox Grid.Column="1"
|
||||
ItemsSource="{Binding StatusOptions}"
|
||||
SelectedItem="{Binding SelectedStatus, Mode=TwoWay}"
|
||||
ToolTip.Tip="Set status (no transition guards)"
|
||||
VerticalAlignment="Top"
|
||||
MinWidth="110"
|
||||
Margin="6,0,0,0"/>
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-btn star-btn"
|
||||
Classes.on="{Binding Task.IsStarred}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
<Button Grid.Column="3" Classes="icon-btn"
|
||||
ToolTip.Tip="Agent settings"
|
||||
IsEnabled="{Binding IsAgentSectionEnabled}"
|
||||
VerticalAlignment="Top"
|
||||
@@ -112,34 +115,6 @@
|
||||
</Grid>
|
||||
</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) ── -->
|
||||
<islands:AgentStripView DockPanel.Dock="Bottom"/>
|
||||
|
||||
@@ -147,46 +122,6 @@
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<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 -->
|
||||
<Border Padding="18,12,18,12"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
|
||||
@@ -132,6 +132,9 @@
|
||||
<MenuItem Header="Settings..."
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<MenuItem Header="Worktrees…"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="20,*,Auto">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
@@ -25,6 +26,31 @@ public partial class ListsIslandView : UserControl
|
||||
if (top is null) window.Show();
|
||||
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.done="{Binding Done}">
|
||||
<Border.ContextMenu>
|
||||
<ContextMenu Opening="OnContextMenuOpening">
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Send to queue"
|
||||
IsVisible="{Binding !IsQueued}"
|
||||
IsVisible="{Binding CanSendToQueue}"
|
||||
Click="OnSendToQueueClick"/>
|
||||
<MenuItem Header="Remove from queue"
|
||||
IsVisible="{Binding CanRemoveFromQueue}"
|
||||
Click="OnRemoveFromQueueClick"/>
|
||||
<MenuItem Header="Cancel execution"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
Click="OnCancelExecutionClick"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Set status">
|
||||
<MenuItem Header="Idle" Tag="Idle" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Queued" Tag="Queued" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Running" Tag="Running" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Mark as">
|
||||
<MenuItem Header="Done" Tag="Done" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Failed" Tag="Failed" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Cancelled" Tag="Cancelled" Click="OnSetStatusClick"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Tags" x:Name="TagsMenu"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Run interactively"
|
||||
Click="OnRunInteractivelyClick"/>
|
||||
@@ -99,16 +97,19 @@
|
||||
|
||||
<!-- Title + chip row + live tail -->
|
||||
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||
<TextBlock Classes="task-title"
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="task-title"
|
||||
Text="{Binding Title}" FontSize="14"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextWrapping="Wrap"
|
||||
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
|
||||
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
|
||||
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
|
||||
|
||||
<!-- Badges: DRAFT and planning session -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
|
||||
VerticalAlignment="Center" Margin="4,0,0,0">
|
||||
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
||||
<TextBlock Text="DRAFT"/>
|
||||
</Border>
|
||||
@@ -116,7 +117,7 @@
|
||||
<TextBlock Text="{Binding PlanningBadge}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Chip row -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
@@ -167,21 +168,6 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Tag chips -->
|
||||
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Classes="chip chip-tag">
|
||||
<TextBlock Text="{Binding}"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Live-tail row (visible when running + has tail) -->
|
||||
|
||||
@@ -36,6 +36,12 @@ public partial class TaskRowView : UserControl
|
||||
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)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
@@ -82,37 +88,6 @@ public partial class TaskRowView : UserControl
|
||||
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)
|
||||
{
|
||||
if (DataContext is not TaskRowViewModel row) return;
|
||||
|
||||
@@ -2,6 +2,8 @@ using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
@@ -33,10 +35,55 @@ public partial class TasksIslandView : UserControl
|
||||
await modal.ShowDialog(owner);
|
||||
// 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)
|
||||
{
|
||||
if (DataContext is not TasksIslandViewModel vm) return;
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
Foreground="{DynamicResource TextDimBrush}">
|
||||
<MenuItem Header="Check for updates"
|
||||
Command="{Binding CheckForUpdatesCommand}"/>
|
||||
<MenuItem Header="Restart worker"
|
||||
Command="{Binding RestartWorkerCommand}"/>
|
||||
<MenuItem Header="Worktrees…"
|
||||
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
|
||||
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
@@ -149,10 +153,10 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*" MinWidth="320"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="320" MinWidth="280"/>
|
||||
<ColumnDefinition Width="460" MinWidth="280"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Classes="island" Margin="7">
|
||||
<Border Grid.Column="0" Classes="island" Margin="3">
|
||||
<islands:ListsIslandView DataContext="{Binding Lists}"/>
|
||||
</Border>
|
||||
|
||||
@@ -164,7 +168,7 @@
|
||||
ResizeDirection="Columns"
|
||||
ResizeBehavior="PreviousAndNext"/>
|
||||
|
||||
<Border Grid.Column="2" Classes="island" Margin="7">
|
||||
<Border Grid.Column="2" Classes="island" Margin="3">
|
||||
<islands:TasksIslandView DataContext="{Binding Tasks}"/>
|
||||
</Border>
|
||||
|
||||
@@ -177,7 +181,7 @@
|
||||
ResizeBehavior="PreviousAndNext"
|
||||
IsVisible="{Binding ShowDetails}"/>
|
||||
|
||||
<Border Grid.Column="4" Classes="island" Margin="7"
|
||||
<Border Grid.Column="4" Classes="island" Margin="3"
|
||||
IsVisible="{Binding ShowDetails}">
|
||||
<islands:DetailsIslandView DataContext="{Binding Details}"/>
|
||||
</Border>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using ClaudeDo.Ui.Views.Modals;
|
||||
using ClaudeDo.Ui.Views.Planning;
|
||||
|
||||
@@ -31,6 +34,27 @@ public partial class MainWindow : Window
|
||||
aboutVm.CloseAction = () => { dlg.Close(); tcs.TrySetResult(true); };
|
||||
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"
|
||||
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.WorktreeModalView"
|
||||
x:DataType="vm:WorktreeModalViewModel"
|
||||
Title="Worktree"
|
||||
Width="640" Height="720"
|
||||
Width="1100" Height="720"
|
||||
MinWidth="640" MinHeight="400"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
SystemDecorations="None"
|
||||
SystemDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
Background="Transparent"
|
||||
CanResize="False"
|
||||
CanResize="True"
|
||||
TransparencyLevelHint="AcrylicBlur">
|
||||
|
||||
<Window.Resources>
|
||||
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
|
||||
</Window.Resources>
|
||||
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
@@ -39,27 +46,64 @@
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
|
||||
<!-- File tree -->
|
||||
<TreeView DockPanel.Dock="Top" ItemsSource="{Binding Root}"
|
||||
Background="Transparent" Margin="8,0,8,8">
|
||||
<TreeView.ItemTemplate>
|
||||
<TreeDataTemplate DataType="vm:WorktreeNodeViewModel"
|
||||
ItemsSource="{Binding Children}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="12"
|
||||
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"
|
||||
<!-- Split: file tree | splitter | diff pane -->
|
||||
<Grid ColumnDefinitions="260,4,*">
|
||||
|
||||
<!-- Left: file tree -->
|
||||
<TreeView x:Name="FileTree"
|
||||
Grid.Column="0"
|
||||
ItemsSource="{Binding Root}"
|
||||
SelectedItem="{Binding SelectedNode, Mode=TwoWay}"
|
||||
Background="Transparent"
|
||||
Margin="8,0,4,8">
|
||||
<TreeView.Styles>
|
||||
<Style Selector="TreeViewItem" x:DataType="vm:WorktreeNodeViewModel">
|
||||
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
|
||||
</Style>
|
||||
</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}"/>
|
||||
<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>
|
||||
</StackPanel>
|
||||
</TreeDataTemplate>
|
||||
</TreeView.ItemTemplate>
|
||||
</TreeView>
|
||||
</TreeDataTemplate>
|
||||
</TreeView.ItemTemplate>
|
||||
</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>
|
||||
</Border>
|
||||
|
||||
@@ -5,6 +5,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Modals;
|
||||
@@ -21,6 +22,18 @@ public partial class WorktreeModalView : Window
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is WorktreeModalViewModel vm)
|
||||
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)
|
||||
@@ -44,6 +57,15 @@ public partial class WorktreeModalView : Window
|
||||
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)
|
||||
{
|
||||
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 TagDto(long Id, string Name);
|
||||
|
||||
public sealed record TaskDto(
|
||||
string Id,
|
||||
string ListId,
|
||||
@@ -32,7 +30,6 @@ public sealed class ExternalMcpService
|
||||
private readonly ListRepository _lists;
|
||||
private readonly QueueService _queue;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly TagRepository _tags;
|
||||
private readonly ITaskStateService _state;
|
||||
|
||||
public ExternalMcpService(
|
||||
@@ -40,14 +37,12 @@ public sealed class ExternalMcpService
|
||||
ListRepository lists,
|
||||
QueueService queue,
|
||||
HubBroadcaster broadcaster,
|
||||
TagRepository tags,
|
||||
ITaskStateService state)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_lists = lists;
|
||||
_queue = queue;
|
||||
_broadcaster = broadcaster;
|
||||
_tags = tags;
|
||||
_state = state;
|
||||
}
|
||||
|
||||
@@ -91,14 +86,13 @@ public sealed class ExternalMcpService
|
||||
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(
|
||||
string listId,
|
||||
string title,
|
||||
string? description,
|
||||
string createdBy,
|
||||
bool queueImmediately,
|
||||
IReadOnlyList<string>? tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(listId))
|
||||
@@ -124,9 +118,6 @@ public sealed class ExternalMcpService
|
||||
};
|
||||
await _tasks.AddAsync(entity, cancellationToken);
|
||||
|
||||
if (tags is not null && tags.Count > 0)
|
||||
await _tasks.SetTagsAsync(entity.Id, tags, cancellationToken);
|
||||
|
||||
if (queueImmediately)
|
||||
{
|
||||
// Routes through TaskStateService so the queue is woken automatically.
|
||||
@@ -140,13 +131,12 @@ public sealed class ExternalMcpService
|
||||
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(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
string? commitType,
|
||||
IReadOnlyList<string>? tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
@@ -159,9 +149,6 @@ public sealed class ExternalMcpService
|
||||
if (commitType is not null) task.CommitType = commitType;
|
||||
await _tasks.UpdateAsync(task, cancellationToken);
|
||||
|
||||
if (tags is not null)
|
||||
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
|
||||
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToDto(reload);
|
||||
@@ -239,30 +226,6 @@ public sealed class ExternalMcpService
|
||||
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(
|
||||
t.Id,
|
||||
t.ListId,
|
||||
|
||||
@@ -29,6 +29,22 @@ public record AppSettingsDto(
|
||||
|
||||
public record WorktreeCleanupDto(int Removed);
|
||||
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 MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||
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,
|
||||
DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "",
|
||||
DefaultModel = dto.DefaultModel ?? "sonnet",
|
||||
DefaultModel = dto.DefaultModel ?? ModelRegistry.DefaultAlias,
|
||||
DefaultMaxTurns = dto.DefaultMaxTurns,
|
||||
DefaultPermissionMode = dto.DefaultPermissionMode ?? "bypassPermissions",
|
||||
DefaultPermissionMode = dto.DefaultPermissionMode ?? PermissionModeRegistry.DefaultMode,
|
||||
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling",
|
||||
CentralWorktreeRoot = dto.CentralWorktreeRoot,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -232,6 +248,33 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
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(
|
||||
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.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 _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");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
@@ -388,7 +396,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
}
|
||||
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;
|
||||
}
|
||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||
@@ -408,10 +417,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
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);
|
||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||
var outcome = await _planning.DiscardAsync(taskId, dequeueQueuedChildren, Context.ConnectionAborted);
|
||||
if (outcome.Result == DiscardPlanningResult.Discarded)
|
||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
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.
|
||||
// - Running children abort the operation — the chain cannot be reshaped while
|
||||
// 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.
|
||||
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.");
|
||||
|
||||
var children = await ctx.Tasks
|
||||
.Include(t => t.Tags)
|
||||
.Where(t => t.ParentTaskId == parentTaskId)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
@@ -49,18 +47,6 @@ public sealed class PlanningChainCoordinator
|
||||
throw new InvalidOperationException(
|
||||
$"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
|
||||
// (terminal) results in place.
|
||||
var sequenceable = children
|
||||
|
||||
@@ -8,7 +8,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList<string> Tags);
|
||||
public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status);
|
||||
public sealed record CreatedChildDto(string TaskId, string Status);
|
||||
|
||||
[McpServerToolType]
|
||||
@@ -41,12 +41,11 @@ public sealed class PlanningMcpService
|
||||
public async Task<CreatedChildDto> CreateChildTask(
|
||||
string title,
|
||||
string? description,
|
||||
IReadOnlyList<string>? tags,
|
||||
string? commitType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken);
|
||||
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
return new CreatedChildDto(child.Id, child.Status.ToString());
|
||||
@@ -58,24 +57,19 @@ public sealed class PlanningMcpService
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
|
||||
var list = new List<ChildTaskDto>(children.Count);
|
||||
foreach (var c in children)
|
||||
{
|
||||
var tagList = await _tasks.GetTagsAsync(c.Id, cancellationToken);
|
||||
list.Add(new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString(), tagList.Select(t => t.Name).ToList()));
|
||||
}
|
||||
return list;
|
||||
return children
|
||||
.Select(c => new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static readonly TaskStatus[] EditableStatuses =
|
||||
{ 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(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
IReadOnlyList<string>? tags,
|
||||
string? commitType,
|
||||
string? status,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -101,13 +95,12 @@ public sealed class PlanningMcpService
|
||||
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 tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(reload.Id, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList());
|
||||
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString());
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
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();
|
||||
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);
|
||||
|
||||
@@ -251,8 +256,7 @@ public sealed class PlanningSessionManager
|
||||
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
if (!ok)
|
||||
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
|
||||
return outcome;
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
using System.Diagnostics;
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Worker.Planning;
|
||||
|
||||
public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher
|
||||
{
|
||||
private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill";
|
||||
private const string Model = "claude-opus-4-7";
|
||||
private const string Model = ModelRegistry.PlanningAlias;
|
||||
|
||||
private readonly string _wtPath;
|
||||
private readonly string _claudePath;
|
||||
|
||||
@@ -27,6 +27,7 @@ builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
||||
|
||||
builder.Services.AddSingleton(cfg);
|
||||
builder.Services.AddHostedService<StaleTaskRecovery>();
|
||||
builder.Services.AddHostedService<OrphanRecovery>();
|
||||
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
@@ -100,6 +101,10 @@ builder.Services.AddSingleton(sp =>
|
||||
sp.GetRequiredService<ITaskStateService>(),
|
||||
sp.GetRequiredService<PlanningChainCoordinator>(),
|
||||
planningSessionsDir));
|
||||
builder.Services.AddHostedService(sp => new PlanningLineageRecovery(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
planningSessionsDir,
|
||||
sp.GetRequiredService<ILogger<PlanningLineageRecovery>>()));
|
||||
builder.Services.AddSingleton<IPlanningTerminalLauncher>(sp =>
|
||||
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -180,7 +185,6 @@ if (cfg.ExternalMcpPort > 0)
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||
externalBuilder.Services.AddScoped<TaskRepository>();
|
||||
externalBuilder.Services.AddScoped<ListRepository>();
|
||||
externalBuilder.Services.AddScoped<TagRepository>();
|
||||
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
||||
externalBuilder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
|
||||
@@ -362,19 +362,14 @@ public sealed class TaskRunner
|
||||
TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct)
|
||||
{
|
||||
AppSettingsEntity global;
|
||||
bool isAgentTask;
|
||||
using (var ctx = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var settingsRepo = new AppSettingsRepository(ctx);
|
||||
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 agentFile = isAgentTask ? PromptFiles.ReadOrNull(PromptKind.Agent) : null;
|
||||
var agentFile = PromptFiles.ReadOrNull(PromptKind.Agent);
|
||||
|
||||
var instructions = MergeInstructions(
|
||||
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 ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
public sealed record ForceRemoveResult(bool Removed, string? Reason);
|
||||
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly GitService _git;
|
||||
@@ -24,16 +25,19 @@ public sealed class WorktreeMaintenanceService
|
||||
_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();
|
||||
var rows = 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.State == WorktreeState.Merged || w.State == WorktreeState.Discarded
|
||||
select new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir))
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
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
|
||||
where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded
|
||||
select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir), ListId = t.ListId };
|
||||
|
||||
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;
|
||||
foreach (var row in rows)
|
||||
@@ -68,6 +72,53 @@ public sealed class WorktreeMaintenanceService
|
||||
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)
|
||||
{
|
||||
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, string, DateTime>? TaskFinishedEvent;
|
||||
public event Action<string>? TaskUpdatedEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string, string>? TaskMessageEvent;
|
||||
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 StartPlanningSessionAsync(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<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||
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, string, DateTime>? TaskFinishedEvent;
|
||||
public event Action<string>? TaskUpdatedEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string, string>? TaskMessageEvent;
|
||||
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 StartPlanningSessionAsync(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<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||
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, string, DateTime>? TaskFinishedEvent;
|
||||
public event Action<string>? TaskUpdatedEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string, string>? TaskMessageEvent;
|
||||
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 StartPlanningSessionAsync(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<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||
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 TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TagRepository _tags;
|
||||
private readonly ExternalFakeHubContext _hub = new();
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
|
||||
@@ -61,7 +60,6 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
_tags = new TagRepository(_ctx);
|
||||
_broadcaster = new HubBroadcaster(_hub);
|
||||
}
|
||||
|
||||
@@ -89,12 +87,8 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
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) =>
|
||||
new(_tasks, _lists, queue, _broadcaster, _tags,
|
||||
new(_tasks, _lists, queue, _broadcaster,
|
||||
TaskStateServiceBuilder.Build(_db.CreateFactory()).State);
|
||||
|
||||
private QueueService CreateQueue()
|
||||
@@ -129,54 +123,6 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
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]
|
||||
public async Task UpdateTask_PatchesNonNullFieldsOnly()
|
||||
{
|
||||
@@ -185,29 +131,13 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var queue = CreateQueue();
|
||||
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);
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
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]
|
||||
public async Task UpdateTask_OnRunning_Throws()
|
||||
{
|
||||
@@ -217,7 +147,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None));
|
||||
sut.UpdateTask(task.Id, "x", null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -227,15 +157,14 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
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]
|
||||
public async Task DeleteTask_RemovesTaskAndTagJoins()
|
||||
public async Task DeleteTask_RemovesTask()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
@@ -265,34 +194,4 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
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();
|
||||
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||
await _tasks.CreateChildAsync(taskId, "child 1", null, null, null);
|
||||
await _tasks.CreateChildAsync(taskId, "child 2", null, null, null);
|
||||
await _tasks.CreateChildAsync(taskId, "child 1", null, null);
|
||||
await _tasks.CreateChildAsync(taskId, "child 2", null, null);
|
||||
_proxy.Sent.Clear();
|
||||
|
||||
var hub = CreateHub();
|
||||
@@ -158,8 +158,8 @@ public sealed class PlanningHubTests : IDisposable
|
||||
{
|
||||
var (_, taskId) = await SeedAsync();
|
||||
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||
await _tasks.CreateChildAsync(taskId, "c1", null, null, null);
|
||||
await _tasks.CreateChildAsync(taskId, "c2", null, null, null);
|
||||
await _tasks.CreateChildAsync(taskId, "c1", null, null);
|
||||
await _tasks.CreateChildAsync(taskId, "c2", null, null);
|
||||
|
||||
var hub = CreateHub();
|
||||
var count = await hub.GetPendingDraftCountAsync(taskId);
|
||||
|
||||
@@ -65,7 +65,6 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
||||
await using var ctx = _factory.CreateDbContext();
|
||||
return await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Tags)
|
||||
.Where(t => t.ParentTaskId == parentId)
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.ToListAsync();
|
||||
@@ -88,17 +87,6 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
||||
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]
|
||||
public async Task SetupChain_AcceptsIdleChildren()
|
||||
{
|
||||
|
||||
@@ -111,8 +111,8 @@ public sealed class PlanningEndToEndTests : IDisposable
|
||||
// Wire the ambient context so _svc reads the correct parent
|
||||
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
||||
|
||||
await _svc.CreateChildTask("sub 1", null, null, null, CancellationToken.None);
|
||||
await _svc.CreateChildTask("sub 2", null, null, null, CancellationToken.None);
|
||||
await _svc.CreateChildTask("sub 1", null, null, CancellationToken.None);
|
||||
await _svc.CreateChildTask("sub 2", null, null, CancellationToken.None);
|
||||
|
||||
var count = await _svc.Finalize(true, CancellationToken.None);
|
||||
Assert.Equal(2, count);
|
||||
@@ -154,9 +154,9 @@ public sealed class PlanningEndToEndTests : IDisposable
|
||||
await _manager.StartAsync(parent.Id, CancellationToken.None);
|
||||
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
||||
|
||||
await _svc.CreateChildTask("c1", null, null, null, CancellationToken.None);
|
||||
await _svc.CreateChildTask("c2", null, null, null, CancellationToken.None);
|
||||
await _svc.CreateChildTask("c3", null, null, null, CancellationToken.None);
|
||||
await _svc.CreateChildTask("c1", null, null, CancellationToken.None);
|
||||
await _svc.CreateChildTask("c2", null, null, CancellationToken.None);
|
||||
await _svc.CreateChildTask("c3", null, null, CancellationToken.None);
|
||||
|
||||
var kidsBefore = await _tasks.GetChildrenAsync(parent.Id);
|
||||
var firstChildId = kidsBefore[0].Id;
|
||||
|
||||
@@ -108,7 +108,7 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
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);
|
||||
var child = await _tasks.GetByIdAsync(result.TaskId);
|
||||
@@ -122,8 +122,8 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
var other = await SeedPlanningParentAsync();
|
||||
|
||||
await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null);
|
||||
await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "mine", null, null);
|
||||
await _tasks.CreateChildAsync(other.Id, "theirs", null, null);
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
var list = await sut.ListChildTasks(CancellationToken.None);
|
||||
@@ -136,18 +136,18 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
||||
{
|
||||
var parent = 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);
|
||||
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]
|
||||
public async Task UpdateChildTask_AfterFinalize_Throws()
|
||||
{
|
||||
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
|
||||
// is the gate the MCP service checks.
|
||||
var sut = BuildSut(parent.Id);
|
||||
@@ -155,47 +155,18 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
||||
Assert.True(result.Ok, result.Reason);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateChildTask(c.Id, "new", null, 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]);
|
||||
sut.UpdateChildTask(c.Id, "new", null, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateChildTask_SetsStatus()
|
||||
{
|
||||
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();
|
||||
|
||||
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);
|
||||
var loaded = await _tasks.GetByIdAsync(c.Id);
|
||||
@@ -206,31 +177,31 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
||||
public async Task UpdateChildTask_DisallowedStatus_Throws()
|
||||
{
|
||||
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();
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
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]
|
||||
public async Task UpdateChildTask_UnknownStatus_Throws()
|
||||
{
|
||||
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();
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
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]
|
||||
public async Task DeleteChildTask_RemovesDraft()
|
||||
{
|
||||
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);
|
||||
await sut.DeleteChildTask(c.Id, CancellationToken.None);
|
||||
@@ -255,8 +226,8 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
||||
public async Task Finalize_PromotesDraftsAndInvalidatesToken()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
var count = await sut.Finalize(true, CancellationToken.None);
|
||||
@@ -273,7 +244,7 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
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();
|
||||
Assert.Contains(result.TaskId, ids);
|
||||
@@ -284,11 +255,11 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
||||
public async Task UpdateChildTask_BroadcastsBothChildAndParent()
|
||||
{
|
||||
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();
|
||||
|
||||
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();
|
||||
Assert.Contains(c.Id, ids);
|
||||
@@ -299,7 +270,7 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
||||
public async Task DeleteChildTask_BroadcastsBothChildAndParent()
|
||||
{
|
||||
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);
|
||||
await sut.DeleteChildTask(c.Id, CancellationToken.None);
|
||||
@@ -313,8 +284,8 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
||||
public async Task Finalize_BroadcastsEachChildAndParent()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
|
||||
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
await sut.Finalize(true, CancellationToken.None);
|
||||
|
||||
@@ -131,7 +131,7 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
||||
var (listId, _) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
await _tasks.SetPlanningStartedAsync(parent.Id, "t");
|
||||
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.StartAsync(child.Id, CancellationToken.None));
|
||||
@@ -182,8 +182,8 @@ public sealed class PlanningSessionManagerTests : IDisposable
|
||||
var (listId, _) = await SeedListAsync();
|
||||
var parent = await SeedManualTaskAsync(listId);
|
||||
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
|
||||
|
||||
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 parent = await SeedManualTaskAsync(listId);
|
||||
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c3", null, null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
|
||||
await _tasks.CreateChildAsync(parent.Id, "c3", null, null);
|
||||
|
||||
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);
|
||||
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));
|
||||
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);
|
||||
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));
|
||||
// branch deleted
|
||||
|
||||
@@ -13,7 +13,6 @@ public sealed class QueuePickerTests : IDisposable
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TagRepository _tags;
|
||||
private readonly QueuePicker _picker;
|
||||
|
||||
public QueuePickerTests()
|
||||
@@ -21,7 +20,6 @@ public sealed class QueuePickerTests : IDisposable
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
_tags = new TagRepository(_ctx);
|
||||
_picker = new QueuePicker(_db.CreateFactory());
|
||||
}
|
||||
|
||||
@@ -40,11 +38,6 @@ public sealed class QueuePickerTests : IDisposable
|
||||
Name = "Test",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
if (listAgentTag)
|
||||
{
|
||||
var tagId = await _tags.GetOrCreateAsync("agent");
|
||||
await _lists.AddTagAsync(listId, tagId);
|
||||
}
|
||||
return listId;
|
||||
}
|
||||
|
||||
@@ -69,11 +62,6 @@ public sealed class QueuePickerTests : IDisposable
|
||||
CommitType = "feat",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
if (taskAgentTag)
|
||||
{
|
||||
var tagId = await _tags.GetOrCreateAsync("agent");
|
||||
await _tasks.AddTagAsync(task.Id, tagId);
|
||||
}
|
||||
if (sortOrder is not null)
|
||||
{
|
||||
task.SortOrder = sortOrder.Value;
|
||||
|
||||
@@ -10,13 +10,11 @@ public sealed class ListRepositoryTests : IDisposable
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TagRepository _tags;
|
||||
|
||||
public ListRepositoryTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_lists = new ListRepository(_ctx);
|
||||
_tags = new TagRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -95,20 +93,4 @@ public sealed class ListRepositoryTests : IDisposable
|
||||
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 TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TagRepository _tags;
|
||||
|
||||
public TaskRepositoryPlanningTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
_tags = new TagRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -97,7 +95,6 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
||||
parent.Id,
|
||||
title: "child title",
|
||||
description: "child desc",
|
||||
tagNames: new[] { "agent" },
|
||||
commitType: "feat");
|
||||
|
||||
Assert.Equal(TaskStatus.Idle, child.Status);
|
||||
@@ -110,9 +107,6 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
||||
var loaded = await _tasks.GetByIdAsync(child.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(TaskStatus.Idle, loaded!.Status);
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(child.Id);
|
||||
Assert.Contains(tags, t => t.Name == "agent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -122,7 +116,7 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
||||
_ = listId;
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null, null));
|
||||
_tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -202,12 +196,12 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
||||
await _tasks.AddAsync(parent);
|
||||
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42");
|
||||
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", 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(c2.Id));
|
||||
|
||||
@@ -226,9 +220,9 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
||||
var task = MakeTask(listId);
|
||||
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]
|
||||
@@ -237,7 +231,7 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
|
||||
var listId = await CreateListAsync();
|
||||
var parent = MakeTask(listId, phase: PlanningPhase.Active);
|
||||
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 () =>
|
||||
{
|
||||
|
||||
@@ -12,14 +12,12 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TagRepository _tags;
|
||||
|
||||
public TaskRepositoryTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
_tags = new TagRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -239,83 +237,4 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
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 TaskRepository _taskRepo;
|
||||
private readonly ListRepository _listRepo;
|
||||
private readonly TagRepository _tagRepo;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly string _tempDir;
|
||||
|
||||
@@ -27,7 +26,6 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
|
||||
_ctx = _db.CreateContext();
|
||||
_taskRepo = new TaskRepository(_ctx);
|
||||
_listRepo = new ListRepository(_ctx);
|
||||
_tagRepo = new TagRepository(_ctx);
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_slotguard_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_cfg = new WorkerConfig
|
||||
@@ -68,9 +66,6 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ public sealed class QueueServiceTests : IDisposable
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _taskRepo;
|
||||
private readonly ListRepository _listRepo;
|
||||
private readonly TagRepository _tagRepo;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly string _tempDir;
|
||||
|
||||
@@ -28,7 +27,6 @@ public sealed class QueueServiceTests : IDisposable
|
||||
_ctx = _db.CreateContext();
|
||||
_taskRepo = new TaskRepository(_ctx);
|
||||
_listRepo = new ListRepository(_ctx);
|
||||
_tagRepo = new TagRepository(_ctx);
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_cfg = new WorkerConfig
|
||||
@@ -69,11 +67,7 @@ public sealed class QueueServiceTests : IDisposable
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
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, agentTag.Id);
|
||||
return (listId, 0L);
|
||||
}
|
||||
|
||||
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();
|
||||
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.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -24,6 +25,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||
public event Action<string>? TaskUpdatedEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string, string>? TaskMessageEvent;
|
||||
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 UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => 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 StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; }
|
||||
public Task OpenInteractiveTerminalAsync(string taskId, 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 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<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user