27 Commits

Author SHA1 Message Date
mika kuns
0b19ea739c Merge feat/worktree-overview-modal 2026-05-19 11:55:34 +02:00
mika kuns
3587703fe8 feat(ui): auto-select first changed file in diff modal 2026-05-19 11:52:57 +02:00
mika kuns
7e3ae704fe fix(ui): default-expand diff tree; reliable row-click toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:50:36 +02:00
mika kuns
232d7cb647 fix(ui): toggle expand on full folder row click
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:48:07 +02:00
mika kuns
6c8048d0be fix(ui): use BorderOnly chrome; color diff +/- lines
Apply SystemDecorations=BorderOnly + ExtendClientAreaTitleBarHeightHint=-1
to WorktreesOverviewModalView and WorktreeModalView for reliable OS resize
borders. Replace SelectedFileDiff SelectableTextBlock with per-line
ItemsControl using WorktreeDiffLineKind coloring via DiffLineKindToBrushConverter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:43:47 +02:00
mika kuns
6670771040 fix(ui): make overview modal resizable; add diff content pane
Drop outer Border wrapper in WorktreesOverviewModalView so Avalonia edge
resize handles reach the window frame. Add split pane to WorktreeModalView
with file tree on left and per-file unified diff on right; wire SelectedNode
via SelectedItem TwoWay binding + SelectionChanged fallback; add
GetFileDiffAsync to GitService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:33:00 +02:00
mika kuns
bc15c16e44 fix(ui): resizable modal, drop branch column, show committed diff
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:08:52 +02:00
mika kuns
ca71275fc4 feat(ui): polish worktrees overview modal
- Restyle to match ListSettingsModalView: custom title bar, DeepBrush
  toolbar, LineBrush footer, SurfaceBrush outer border, no system chrome
- Add column header row (TASK / BRANCH / STATE / DIFF / AGE) with
  TextFaintBrush + MonoFont + LetterSpacing, separator line below
- Replace wt-row style with task-row-equivalent: transparent bg,
  CornerRadius 8, 1px border, :pointerover + .selected transitions
- Add IsSelected to WorktreeOverviewRowViewModel; SelectRow() helper
  on modal VM clears previous selection before setting new one
- Wire OnRowTapped in code-behind for click-to-select
- Wire ShowDiff: VM takes Func<WorktreeModalViewModel> factory, builds
  diffVm and delegates window creation to both call sites (MainWindow
  and ListsIslandView); register Func<WorktreeModalViewModel> in DI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:38:19 +02:00
mika kuns
8f4e37ef56 fix(ui): preserve status message after cleanup; English label
Remove StatusMessage reset from LoadAsync so CleanupFinished result survives the reload; reset moved to Refresh command only. Also rename German context-menu label to "Worktrees…".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:55:32 +02:00
mika kuns
789094fcd9 feat(ui): wire worktree overview modal entry points
Add list context-menu command (per-list mode) and Help menu entry (global mode) for the WorktreesOverviewModal; register VM and factory in DI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:49:44 +02:00
mika kuns
9f70f6747e feat(ui): add WorktreesOverviewModalView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:44:20 +02:00
mika kuns
182a9df7f3 feat(ui): add WorktreesOverviewModalViewModel 2026-05-19 09:42:37 +02:00
mika kuns
79131f83c1 feat(ui): add WorktreeStateColorConverter 2026-05-19 09:42:33 +02:00
mika kuns
b888a5f0cd feat(ui): expose worktree overview client methods
Add GetWorktreesOverviewAsync, SetWorktreeStateAsync, ForceRemoveWorktreeAsync wrappers; update CleanupFinishedWorktreesAsync to accept optional listId; append WorktreeOverviewDto and ForceRemoveResultDto records.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:39:01 +02:00
mika kuns
046da0fd81 feat(hub): expose worktree overview, state mutation, force-remove
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:37:29 +02:00
mika kuns
b095a29f97 feat(worktrees): add ForceRemoveAsync for targeted removal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:34:32 +02:00
mika kuns
ce30d01b72 feat(worktrees): add GetOverviewAsync for overview modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:32:07 +02:00
mika kuns
89f6b836ba feat(worktrees): allow CleanupFinishedAsync to filter by list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:29:27 +02:00
mika kuns
b944597af4 docs: add worktree overview modal spec and plan 2026-05-19 09:27:19 +02:00
mika kuns
5da69ee6aa refactor(config): consolidate commit types into CommitTypeRegistry
Replaces six scattered "chore" literals across TaskEntity, ListEntity,
WorkerHub, ListsIslandViewModel, ListNavItemViewModel and the inline
commit type list in ListSettingsModalViewModel.
2026-05-19 09:00:00 +02:00
mika kuns
5308ba3136 refactor(config): consolidate permission modes into PermissionModeRegistry
Also fixes WorkerHub.UpdateAppSettings falling back to "bypassPermissions"
when AppSettingsEntity and the runtime default are "auto". The fallback
now matches the entity default.
2026-05-19 08:59:16 +02:00
mika kuns
a62ef240d1 refactor(config): consolidate model aliases into ModelRegistry
Replaces three scattered model lists (ListSettingsModalViewModel,
DetailsIslandViewModel, GeneralSettingsTabViewModel) and the hardcoded
planning model with a single source. Planning launcher now uses the
opus alias instead of pinning claude-opus-4-7.
2026-05-19 08:58:43 +02:00
mika kuns
623ebf147b refactor(tags): remove tag entity and all references
Drops TagEntity, TagRepository, and tag wiring across data layer, worker,
and UI. Adds RemoveTags migration to clean up schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:07:24 +02:00
mika kuns
8d34db3f9b feat(ui): add Restart worker menu entry under Help
Stops and starts the ClaudeDoWorker Windows service via
ServiceController. SignalR auto-reconnect plus the existing
ConnectionRestoredEvent handle the refresh, so the UI repopulates
counters and the active list once the worker is back up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:39:40 +02:00
mika kuns
0d55002e5e refactor(planning): dequeue orphans instead of promoting, restore lost lineage
Three behavioral changes around stuck planning subtasks:

- OrphanRecovery no longer clears ParentTaskId. Queued children of a
  parent that is not in a planning phase are dequeued (Status: Queued
  -> Idle, BlockedByTaskId cleared) but stay attached to the parent so
  the historical lineage is preserved.
- DiscardPlanningAsync stops promoting terminal (Done/Failed/Cancelled)
  children to top-level for the same reason - they remain ChildTasks of
  the (now non-planning) parent.
- New PlanningLineageRecovery hosted service scans
  ~/.todo-app/planning-sessions/ and re-attaches a single, unambiguous
  blocked-by chain to its original planning parent when the
  parent_task_id links were lost. Refuses to guess when multiple
  candidate chains exist.

UI now exposes ConnectionRestoredEvent on IWorkerClient, fired on first
connect and every reconnect. ListsIslandViewModel refreshes counters
and TasksIslandViewModel reloads the current list - so stale counts no
longer survive a worker restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:28:57 +02:00
mika kuns
d094a21e09 feat(planning): prevent orphaned subtasks via guards + startup repair
Three coordinated guards close the orphan-creation paths:

- CreateChildAsync refuses when the parent is not in a planning phase.
- DiscardPlanningAsync now returns a structured DiscardPlanningOutcome
  and refuses when children are queued or running; callers can opt into
  auto-dequeuing queued kids via dequeueQueuedChildren=true. Terminal
  children (Done/Failed/Cancelled) are promoted to top-level instead of
  becoming orphans when the parent's PlanningPhase is reset.
- OrphanRecovery hosted service clears ParentTaskId on any rows whose
  parent is missing or no longer in a planning phase on worker startup,
  mirroring the StaleTaskRecovery pattern.

UI surfaces the block reason: a confirm dialog offers to dequeue queued
children and retry; a running-children block is shown as a hard error
asking the user to cancel first.

WorkerClient now negotiates the JsonStringEnumConverter so the
DiscardPlanningResult enum round-trips correctly over SignalR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:02:15 +02:00
mika kuns
e68bb737e3 refactor(filtering): consolidate task list filters into single strategy registry
Replace the three drifting filter implementations (counter, list loader,
regroup) with one ITaskListFilter strategy per list kind. Counter and list
loader now share the same predicates, so they cannot diverge again. Planning
hierarchy rules (parent-as-context, orphan handling) live in PlanningRules
and are unit-tested via 29 new tests in ClaudeDo.Data.Tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:18:33 +02:00
96 changed files with 4592 additions and 1268 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}

View 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;
}

View 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;
}

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

View 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;
}

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

View 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;
}

View 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;
}

View 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;
}
}

View 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;
}
}

View File

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

View 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");
}
}
}

View File

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

View 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";
}

View File

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

View 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)";
}

View 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";
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
{

View File

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

View File

@@ -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()
{

View File

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

View File

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

View File

@@ -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 &amp; 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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;
}

View 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;
}

View File

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

View File

@@ -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.")]

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View 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;
}
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () =>
{

View File

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

View File

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

View File

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

View File

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

View File

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