Compare commits
64 Commits
a135485339
...
feat/plann
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09b52140ce | ||
|
|
e7d595244e | ||
| 993851009b | |||
|
|
450e685580 | ||
|
|
0e116bec7b | ||
|
|
47b49743c0 | ||
|
|
506caa2c53 | ||
|
|
388a8c1fae | ||
|
|
42b208ff28 | ||
|
|
309f84b388 | ||
|
|
00608401aa | ||
|
|
229d4bbb2b | ||
| 845359b885 | |||
|
|
d4a46420c9 | ||
|
|
f704244b84 | ||
|
|
782110604b | ||
|
|
19bf032a2e | ||
|
|
b7464c9a11 | ||
|
|
524aaf85af | ||
|
|
a9e7479326 | ||
|
|
2e80cc606e | ||
|
|
d099138487 | ||
|
|
2278d97b7e | ||
|
|
74255ddc82 | ||
|
|
b466246c1b | ||
|
|
b3eb39a28b | ||
|
|
253e6f05e0 | ||
|
|
042a1b47c2 | ||
|
|
7a20534e7c | ||
|
|
ee2cbc92ef | ||
|
|
373f04a034 | ||
|
|
43d517dcfc | ||
|
|
8891d48af2 | ||
|
|
0b72c0fb53 | ||
|
|
a41e5b5b2d | ||
|
|
00c62178e1 | ||
|
|
bbe7d73de2 | ||
|
|
0934b294c2 | ||
|
|
b28d8f2f4a | ||
|
|
ec4ec44603 | ||
|
|
ee09706811 | ||
|
|
c06d1d6afb | ||
|
|
f906e7086c | ||
|
|
caf900b02d | ||
|
|
e80e3fccc0 | ||
|
|
e8056553fd | ||
|
|
ea4d2d7c0c | ||
|
|
98c188a5da | ||
|
|
0c3dcb0052 | ||
|
|
e017d66023 | ||
|
|
ba0b38b4f1 | ||
|
|
5b4cdd366e | ||
|
|
7c0f8d8408 | ||
|
|
0a7fcae137 | ||
|
|
5346737e2b | ||
|
|
80f6669585 | ||
|
|
27054e6715 | ||
|
|
ea7694566d | ||
|
|
46e01aefed | ||
|
|
41e0bea162 | ||
|
|
86012e02b9 | ||
|
|
da19eb807b | ||
|
|
0d37473575 | ||
|
|
6a4bf676ff |
@@ -53,7 +53,7 @@ jobs:
|
|||||||
cd "$WORK/src"
|
cd "$WORK/src"
|
||||||
dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \
|
dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \
|
||||||
-c Release -r win-x64 --self-contained true \
|
-c Release -r win-x64 --self-contained true \
|
||||||
/p:Version=$VERSION -o out/app
|
/p:MinVerVersionOverride=$VERSION -o out/app
|
||||||
|
|
||||||
- name: Publish ClaudeDo.Worker (win-x64, self-contained)
|
- name: Publish ClaudeDo.Worker (win-x64, self-contained)
|
||||||
env:
|
env:
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
cd "$WORK/src"
|
cd "$WORK/src"
|
||||||
dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \
|
dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \
|
||||||
-c Release -r win-x64 --self-contained true \
|
-c Release -r win-x64 --self-contained true \
|
||||||
/p:Version=$VERSION -o out/worker
|
/p:MinVerVersionOverride=$VERSION -o out/worker
|
||||||
|
|
||||||
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
|
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
|
||||||
env:
|
env:
|
||||||
@@ -80,7 +80,7 @@ jobs:
|
|||||||
# Target machines need .NET 8 Desktop Runtime (x64).
|
# Target machines need .NET 8 Desktop Runtime (x64).
|
||||||
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
|
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
|
||||||
-c Release -r win-x64 --self-contained false \
|
-c Release -r win-x64 --self-contained false \
|
||||||
/p:Version=$VERSION /p:PublishSingleFile=true \
|
/p:MinVerVersionOverride=$VERSION /p:PublishSingleFile=true \
|
||||||
-o out/installer
|
-o out/installer
|
||||||
|
|
||||||
- name: Package assets
|
- name: Package assets
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
# Local dev worktrees (created by using-git-worktrees skill)
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# .NET build output
|
# .NET build output
|
||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
|
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
|
||||||
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
|
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
|
||||||
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
|
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
|
||||||
|
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
||||||
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
||||||
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
|
||||||
|
<Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
8
Directory.Build.props
Normal file
8
Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<MinVerTagPrefix>v</MinVerTagPrefix>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MinVer" Version="5.0.0" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
35
docs/open.md
35
docs/open.md
@@ -191,3 +191,38 @@ Im aktuellen Stand kompiliert die UI, aber mehrere Stellen sind als `// TODO` ma
|
|||||||
9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird.
|
9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird.
|
||||||
|
|
||||||
Punkte 1–3 sind ein realistischer Block für eine Session.
|
Punkte 1–3 sind ein realistischer Block für eine Session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Update — Manual Verification
|
||||||
|
|
||||||
|
Preconditions: a working Gitea release at `git.kuns.dev/releases/ClaudeDo` with three assets — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, and `checksums.txt` listing both.
|
||||||
|
|
||||||
|
1. Install a baseline version (e.g. `0.2.x`) normally.
|
||||||
|
2. Publish a new release tagged `v0.3.0` with fresh installer + app zip + checksums.
|
||||||
|
3. Launch the app — confirm the banner appears: `Update available: v0.2.x → v0.3.0`.
|
||||||
|
4. Click **Update now** — app closes, installer opens in Update mode, runs, restarts the worker.
|
||||||
|
5. Re-launch the app — banner is gone; `Help → Check for updates` briefly shows "You're up to date (v0.3.0)".
|
||||||
|
6. Run the `v0.2.x` installer manually — confirm it prompts to self-update to v0.3.0. Click **Update** → running exe is replaced and the wizard opens on the new version.
|
||||||
|
7. Repeat step 6 with **Continue anyway** → wizard opens without self-update.
|
||||||
|
8. Repeat step 6 with **Cancel** → installer exits without any action.
|
||||||
|
9. Kill network during startup in both app and installer → confirm silent fallback (no errors, no banner, wizard opens normally).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning Sessions — Manual Verification (Plan C UI)
|
||||||
|
|
||||||
|
Requires Plan B (worker hub endpoints) merged. Until then, only the UI structure/styling checks are meaningful.
|
||||||
|
|
||||||
|
1. Create a Manual task with a title and a TODO-ish description.
|
||||||
|
2. Right-click the task → **Open planning Session** — Windows Terminal opens with Claude CLI running (Plan B).
|
||||||
|
3. Ask Claude to create two child tasks via `mcp__claudedo__create_child_task`.
|
||||||
|
4. Watch the UI: drafts appear indented under the parent, italic, reduced opacity, with a `DRAFT` badge.
|
||||||
|
5. The parent shows a `PLANNING` badge. Click the chevron → children collapse; click again → children expand.
|
||||||
|
6. Ask Claude to `finalize` — drafts flip to Manual/Queued children; parent flips to `PLANNED` badge.
|
||||||
|
7. In a new planning task, close the terminal without finalize. Right-click the Planning task → the unfinished-session modal opens with Resume / Finalize now / Discard.
|
||||||
|
8. Attempt to delete a parent with children via the details panel — confirm the friendly error dialog appears and the task is NOT deleted.
|
||||||
|
|
||||||
|
**Known followups (non-blocking):**
|
||||||
|
- `Border.badge.planned` style (blue) is defined in `IslandStyles.axaml` but never applied — `TaskRowView` keeps the `planning` class for both Planning and Planned, so Planned gets the amber badge. Either make the view swap `Classes.planned` when status is Planned, or remove the unused style + brush.
|
||||||
|
- Dead `Instance` statics on `BoolToItalicConverter` and `BoolToDraftOpacityConverter` — App.axaml registers instances via the resource dictionary; the static members can be removed.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
927
docs/superpowers/plans/2026-04-23-planning-sessions-plan-c-ui.md
Normal file
927
docs/superpowers/plans/2026-04-23-planning-sessions-plan-c-ui.md
Normal file
@@ -0,0 +1,927 @@
|
|||||||
|
# Planning Sessions — Plan C: UI Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Wire the planning-session feature into the UI: context-menu entries, hierarchical display of parent + children, draft styling, unfinished-session dialog, and the `WorkerClient` methods that call the hub endpoints built in Plan B.
|
||||||
|
|
||||||
|
**Architecture:** Extend `TaskRowViewModel` with hierarchy-aware flags (`IsChild`, `IsPlanningParent`, `IsExpanded`). `TasksIslandViewModel` builds a flat stream that interleaves parents and their children based on expanded state. Context-menu entries on `TaskRowView` gate on task status. Draft styling lives in the existing island styles. A modal dialog reuses the project's `TaskCompletionSource<T>` pattern.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`), compiled bindings, SignalR client.
|
||||||
|
|
||||||
|
**Spec reference:** `docs/superpowers/specs/2026-04-23-planning-sessions-design.md` section 6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisite Gate
|
||||||
|
|
||||||
|
This plan depends on Plan A being merged to `main`. Plan B's interface contract (hub method names, return types) is locked in the spec §6.6 and Plan B task 13 — this plan can proceed in parallel with Plan B.
|
||||||
|
|
||||||
|
Before starting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin main
|
||||||
|
git checkout main
|
||||||
|
git pull --ff-only
|
||||||
|
ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
If the file is missing, wait for Plan A:
|
||||||
|
```bash
|
||||||
|
while ! ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs >/dev/null 2>&1; do
|
||||||
|
echo "Waiting for Plan A to merge..."
|
||||||
|
sleep 60
|
||||||
|
git fetch origin main && git pull --ff-only
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Then branch:
|
||||||
|
```bash
|
||||||
|
git checkout -b feat/planning-sessions-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parallel-with-Plan-B note:** Plan B may not yet be merged when this plan runs. The `WorkerClient` methods in Task 9 will compile against Plan B's SignalR hub method names (they're string-based SignalR invocations), so they don't have a build-time dependency. Runtime end-to-end testing requires Plan B merged; until then, mock-test what's possible and smoke-test manually once both plans land.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — add `ParentTaskId`, `IsChild`, `IsPlanningParent`, `IsExpanded`, `PlanningBadge` properties.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — add planning commands, expanded-state map, flat-stream rebuild logic.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — chevron, indentation, badges, draft styling hooks.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs` — context-menu event handlers (if code-behind is used; else inline).
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` — use the extended TaskRowView template.
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — five new hub method wrappers matching Plan B.
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `.draft`, `.planning-parent`, `.planned-parent`, badge styles.
|
||||||
|
|
||||||
|
**Created:**
|
||||||
|
- `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` + `.axaml.cs` — modal Resume/Finalize/Discard dialog.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs` — dialog VM.
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` — VM-level tests.
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs` — VM-level tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extend `TaskRowViewModel`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||||
|
- Create: `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for planning flags**
|
||||||
|
|
||||||
|
Create the test file. Adapt the existing `TaskRowViewModelTests` pattern (look at `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs` for how VMs are constructed in tests):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.UiVm;
|
||||||
|
|
||||||
|
public sealed class TaskRowViewModelPlanningTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Draft_Status_SetsIsChildFlag_WhenParentIdIsNotNull()
|
||||||
|
{
|
||||||
|
// Adapt the constructor call to your actual TaskRowViewModel signature (see TaskRowViewModelTests).
|
||||||
|
var vm = TestHelpers.MakeRow(
|
||||||
|
status: "draft",
|
||||||
|
parentTaskId: "parent-id");
|
||||||
|
|
||||||
|
Assert.True(vm.IsChild);
|
||||||
|
Assert.False(vm.IsPlanningParent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Planning_Status_SetsIsPlanningParent()
|
||||||
|
{
|
||||||
|
var vm = TestHelpers.MakeRow(status: "planning", parentTaskId: null);
|
||||||
|
|
||||||
|
Assert.True(vm.IsPlanningParent);
|
||||||
|
Assert.False(vm.IsChild);
|
||||||
|
Assert.Equal("PLANNING", vm.PlanningBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Planned_Status_ShowsPlannedBadge()
|
||||||
|
{
|
||||||
|
var vm = TestHelpers.MakeRow(status: "planned", parentTaskId: null);
|
||||||
|
|
||||||
|
Assert.True(vm.IsPlanningParent);
|
||||||
|
Assert.Equal("PLANNED", vm.PlanningBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonPlanningStatus_NoBadge()
|
||||||
|
{
|
||||||
|
var vm = TestHelpers.MakeRow(status: "manual", parentTaskId: null);
|
||||||
|
|
||||||
|
Assert.False(vm.IsPlanningParent);
|
||||||
|
Assert.Null(vm.PlanningBadge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class TestHelpers
|
||||||
|
{
|
||||||
|
public static TaskRowViewModel MakeRow(string status, string? parentTaskId)
|
||||||
|
{
|
||||||
|
// Implement based on actual TaskRowViewModel constructor.
|
||||||
|
// The TaskRowViewModelTests.cs file in the same folder shows the existing pattern.
|
||||||
|
throw new NotImplementedException("Adapt to your TaskRowViewModel constructor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs` first to see how the VM is constructed in tests, then fill in `TestHelpers.MakeRow` accordingly.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run; verify fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRowViewModelPlanningTests"`
|
||||||
|
Expected: FAIL (properties not yet on VM).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend the VM**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` add the new properties using `[ObservableProperty]`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty] private string? parentTaskId;
|
||||||
|
[ObservableProperty] private bool isExpanded = true;
|
||||||
|
|
||||||
|
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||||||
|
public bool IsPlanningParent => string.Equals(Status, "planning", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(Status, "planned", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public string? PlanningBadge => Status switch
|
||||||
|
{
|
||||||
|
string s when string.Equals(s, "planning", StringComparison.OrdinalIgnoreCase) => "PLANNING",
|
||||||
|
string s when string.Equals(s, "planned", StringComparison.OrdinalIgnoreCase) => "PLANNED",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool IsDraft => string.Equals(Status, "draft", StringComparison.OrdinalIgnoreCase);
|
||||||
|
```
|
||||||
|
|
||||||
|
Since `IsChild`, `IsPlanningParent`, `PlanningBadge`, and `IsDraft` are computed from other observables, you must raise property-changed notifications when `Status` or `ParentTaskId` changes. Use `[ObservableProperty]` partial methods:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
partial void OnStatusChanged(string value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsPlanningParent));
|
||||||
|
OnPropertyChanged(nameof(PlanningBadge));
|
||||||
|
OnPropertyChanged(nameof(IsDraft));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnParentTaskIdChanged(string? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsChild));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the existing VM already has `OnStatusChanged` (check for generator outputs), merge into it rather than duplicating.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run; verify pass**
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs
|
||||||
|
git commit -m "feat(ui): TaskRowViewModel gains planning hierarchy flags"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `WorkerClient` planning methods
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- Create: DTOs matching Plan B return types (either inline in the client file or new file `src/ClaudeDo.Ui/Services/PlanningDtos.cs`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add DTOs**
|
||||||
|
|
||||||
|
Create `src/ClaudeDo.Ui/Services/PlanningDtos.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
public sealed record PlanningSessionFilesDto(
|
||||||
|
string SessionDirectory,
|
||||||
|
string McpConfigPath,
|
||||||
|
string SystemPromptPath,
|
||||||
|
string InitialPromptPath);
|
||||||
|
|
||||||
|
public sealed record PlanningSessionStartInfo(
|
||||||
|
string ParentTaskId,
|
||||||
|
string WorkingDir,
|
||||||
|
PlanningSessionFilesDto Files);
|
||||||
|
|
||||||
|
public sealed record PlanningSessionResumeInfo(
|
||||||
|
string ParentTaskId,
|
||||||
|
string WorkingDir,
|
||||||
|
string ClaudeSessionId,
|
||||||
|
string McpConfigPath);
|
||||||
|
```
|
||||||
|
|
||||||
|
These field names must match Plan B's `PlanningSessionStartContext` / `PlanningSessionResumeContext` exactly (case-sensitive JSON deserialization through SignalR).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `WorkerClient` methods**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> _connection.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> _connection.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> _connection.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
||||||
|
=> _connection.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
|
||||||
|
|
||||||
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> _connection.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `_connection` with whatever name the existing `WorkerClient` uses for its `HubConnection` field.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: builds.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services/PlanningDtos.cs src/ClaudeDo.Ui/Services/WorkerClient.cs
|
||||||
|
git commit -m "feat(ui): WorkerClient planning-session methods"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `TasksIslandViewModel` — planning commands + expanded state
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
||||||
|
- Create: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add commands to the VM**
|
||||||
|
|
||||||
|
In `TasksIslandViewModel.cs`, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private readonly Dictionary<string, bool> _expandedState = new();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || !string.Equals(row.Status, "manual", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _workerClient.StartPlanningSessionAsync(row.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _dialogs.ShowErrorAsync("Could not start planning session", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || !row.IsPlanningParent) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _workerClient.ResumePlanningSessionAsync(row.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _dialogs.ShowErrorAsync("Could not resume planning session", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
var confirm = await _dialogs.ConfirmAsync(
|
||||||
|
"Discard planning session?",
|
||||||
|
"This will delete all draft tasks and reset the parent to Manual.");
|
||||||
|
if (!confirm) return;
|
||||||
|
await _workerClient.DiscardPlanningSessionAsync(row.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
await _workerClient.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleExpand(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : true);
|
||||||
|
_expandedState[row.Id] = next;
|
||||||
|
row.IsExpanded = next;
|
||||||
|
RebuildFlatStreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildFlatStreams()
|
||||||
|
{
|
||||||
|
// Existing code builds OpenItems/CompletedItems from the task list.
|
||||||
|
// Modify it so that: after emitting a parent, if IsPlanningParent && IsExpanded,
|
||||||
|
// its Draft / Manual / Queued / Running / Done children are emitted next.
|
||||||
|
// Children already know they are children (ParentTaskId != null) and are styled as such.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `RebuildFlatStreams` (or equivalent) probably just groups tasks by status. You need to intersperse the hierarchy:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Pseudocode — fit to the existing code shape.
|
||||||
|
var topLevel = allRows.Where(r => !r.IsChild).OrderBy(r => r.SortOrder);
|
||||||
|
var flat = new List<TaskRowViewModel>();
|
||||||
|
foreach (var parent in topLevel)
|
||||||
|
{
|
||||||
|
flat.Add(parent);
|
||||||
|
if (parent.IsPlanningParent && parent.IsExpanded)
|
||||||
|
{
|
||||||
|
var children = allRows
|
||||||
|
.Where(r => r.ParentTaskId == parent.Id)
|
||||||
|
.OrderBy(r => r.SortOrder)
|
||||||
|
.ToList();
|
||||||
|
flat.AddRange(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Then bucket `flat` into OpenItems/CompletedItems like today, preserving order.
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass dependencies: the VM already has a `WorkerClient` or equivalent — reuse it. Add a dialog service if not already injected:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IDialogService
|
||||||
|
{
|
||||||
|
Task<bool> ConfirmAsync(string title, string message);
|
||||||
|
Task ShowErrorAsync(string title, string message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If an analog already exists (check existing editor dialogs), use it.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write failing VM tests**
|
||||||
|
|
||||||
|
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.UiVm;
|
||||||
|
|
||||||
|
public sealed class TasksIslandViewModelPlanningTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ToggleExpand_CollapsesChildrenOfPlanningParent()
|
||||||
|
{
|
||||||
|
// Arrange: create VM with one Planning parent and two Draft children.
|
||||||
|
// Act: call ToggleExpandCommand with the parent.
|
||||||
|
// Assert: flat stream no longer contains the children.
|
||||||
|
// Adapt to how the existing TasksIslandViewModel is instantiated.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenPlanningSessionCommand_ManualTaskOnly_CanExecuteTrue()
|
||||||
|
{
|
||||||
|
// Arrange VM with a Manual row.
|
||||||
|
// Assert CanExecute for OpenPlanningSession command is true for Manual rows,
|
||||||
|
// false for Queued/Running/Done/Failed rows.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These are skeleton tests — implement with the same construction pattern used by the existing `TasksIslandViewModelTests` if one exists, or build a minimal VM fake with a stub `WorkerClient`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build + test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TasksIslandViewModelPlanningTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
|
||||||
|
git commit -m "feat(ui): planning commands and expand/collapse in TasksIslandViewModel"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `TaskRowView` — indent, chevron, badges, draft styling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Wrap the row content with a Grid that has an indent column**
|
||||||
|
|
||||||
|
Open `TaskRowView.axaml`. The existing root is likely a `Grid` or `Border`. Replace/refactor the top-level layout to:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
Width="24"
|
||||||
|
IsVisible="{Binding IsChild}">
|
||||||
|
<Rectangle Width="1" Fill="{DynamicResource TextFaintBrush}" HorizontalAlignment="Right" Margin="0,4"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Column="1" ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<!-- Chevron for planning parents -->
|
||||||
|
<Button Grid.Column="0"
|
||||||
|
Classes="icon-btn chevron"
|
||||||
|
Width="18" Height="18"
|
||||||
|
IsVisible="{Binding IsPlanningParent}"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleExpandCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<PathIcon Width="10" Height="10">
|
||||||
|
<PathIcon.Data>
|
||||||
|
<MultiBinding Converter="{StaticResource ChevronDataConverter}">
|
||||||
|
<Binding Path="IsExpanded"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</PathIcon.Data>
|
||||||
|
</PathIcon>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- existing title/description area -->
|
||||||
|
<StackPanel Grid.Column="1" ...>
|
||||||
|
<!-- existing title binding with added italic when IsDraft -->
|
||||||
|
<TextBlock Text="{Binding Title}"
|
||||||
|
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalicConverter}}"
|
||||||
|
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacityConverter}}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Badges -->
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
|
||||||
|
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
||||||
|
<TextBlock Text="DRAFT"/>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
|
||||||
|
<TextBlock Text="{Binding PlanningBadge}"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a structural edit — preserve all existing bindings for status color, completion toggle, star, scheduled-for, etc. The new indentation column and badges are additive.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the converters**
|
||||||
|
|
||||||
|
If `ChevronDataConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter` do not exist, add them to `src/ClaudeDo.Ui/Converters/` (or inline as compiled converters). Example inline:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- in UserControl.Resources of TaskRowView.axaml, or in App.axaml for global -->
|
||||||
|
<Style Selector="Border.badge">
|
||||||
|
<Setter Property="CornerRadius" Value="3"/>
|
||||||
|
<Setter Property="Padding" Value="4,1"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource BadgeBgBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.badge.draft">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.badge.planning">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
```
|
||||||
|
|
||||||
|
If converters must be code-based, a minimal `BoolToItalicConverter`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class BoolToItalicConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
||||||
|
=> value is true ? FontStyle.Italic : FontStyle.Normal;
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Register in `App.axaml` resources.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: builds cleanly (XAML compiles).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Converters/
|
||||||
|
git commit -m "feat(ui): TaskRowView hierarchy indentation, chevron, badges, draft italic"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: `TaskRowView` — planning context-menu entries
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate the existing context menu**
|
||||||
|
|
||||||
|
Open `TaskRowView.axaml`. The ContextMenu lives somewhere on the root element or as a `ContextMenu.Items`/`ContextFlyout`. Find the block that defines entries like "Edit", "Run now", etc.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Insert planning entries conditionally**
|
||||||
|
|
||||||
|
Add within the existing menu (order: after "Run now" and a separator):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<MenuItem Header="Open planning Session"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).OpenPlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding Status, Converter={StaticResource IsManualAndNotChildConverter}, ConverterParameter={Binding IsChild}}"/>
|
||||||
|
|
||||||
|
<MenuItem Header="Resume planning Session"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ResumePlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding Status, Converter={StaticResource IsPlanningConverter}}"/>
|
||||||
|
|
||||||
|
<MenuItem Header="Discard planning session"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).DiscardPlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding Status, Converter={StaticResource IsPlanningConverter}}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Simpler alternative without multi-condition converters: expose direct bool VM properties that combine the logic — `CanOpenPlanningSession`, `CanResumePlanningSession`, `CanDiscardPlanningSession`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// In TaskRowViewModel
|
||||||
|
public bool CanOpenPlanningSession =>
|
||||||
|
string.Equals(Status, "manual", StringComparison.OrdinalIgnoreCase) && !IsChild;
|
||||||
|
|
||||||
|
public bool CanResumeOrDiscardPlanning =>
|
||||||
|
string.Equals(Status, "planning", StringComparison.OrdinalIgnoreCase);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `OnPropertyChanged(nameof(CanOpenPlanningSession))` and friends in the status/parent-id partial methods from Task 1. Then the XAML simplifies to:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<MenuItem Header="Open planning Session"
|
||||||
|
Command="{Binding ...OpenPlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding CanOpenPlanningSession}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this simpler path — cleaner.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: builds.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
|
||||||
|
git commit -m "feat(ui): planning entries in task context menu"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Island styles — draft, badges
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add brushes + styles**
|
||||||
|
|
||||||
|
Append within `<Styles.Resources>` or wherever brushes are defined:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<SolidColorBrush x:Key="DraftBadgeBrush" Color="#4A5568"/>
|
||||||
|
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="#D69E2E"/>
|
||||||
|
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="#3182CE"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add styles:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Style Selector="Border.badge">
|
||||||
|
<Setter Property="CornerRadius" Value="3"/>
|
||||||
|
<Setter Property="Padding" Value="4,1"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge > TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="9"/>
|
||||||
|
<Setter Property="FontWeight" Value="Bold"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.draft">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.planning">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.planned">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build and manually verify**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Launch app, create a task, right-click, use Open planning Session (if Plan B merged) or simulate via DB. Verify badge + italic draft rendering visually.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml
|
||||||
|
git commit -m "feat(ui): draft and planning badge styles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Unfinished-planning-session dialog
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` + `.axaml.cs`
|
||||||
|
- Create: `src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the VM**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
public enum UnfinishedPlanningDialogResult
|
||||||
|
{
|
||||||
|
Cancel,
|
||||||
|
Resume,
|
||||||
|
FinalizeNow,
|
||||||
|
Discard,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class UnfinishedPlanningDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private string title = "Unfinished planning session";
|
||||||
|
[ObservableProperty] private string taskTitle = "";
|
||||||
|
[ObservableProperty] private int draftCount;
|
||||||
|
|
||||||
|
public TaskCompletionSource<UnfinishedPlanningDialogResult> Result { get; } = new();
|
||||||
|
|
||||||
|
[RelayCommand] private void Resume() => Result.TrySetResult(UnfinishedPlanningDialogResult.Resume);
|
||||||
|
[RelayCommand] private void FinalizeNow() => Result.TrySetResult(UnfinishedPlanningDialogResult.FinalizeNow);
|
||||||
|
[RelayCommand] private void Discard() => Result.TrySetResult(UnfinishedPlanningDialogResult.Discard);
|
||||||
|
[RelayCommand] private void Cancel() => Result.TrySetResult(UnfinishedPlanningDialogResult.Cancel);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the view**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Dialogs"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Dialogs.UnfinishedPlanningDialog"
|
||||||
|
x:DataType="vm:UnfinishedPlanningDialogViewModel"
|
||||||
|
Width="440" Height="220"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
CanResize="False"
|
||||||
|
Title="{Binding Title}">
|
||||||
|
<StackPanel Margin="20" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Title}" FontWeight="Bold" FontSize="15"/>
|
||||||
|
<TextBlock Text="{Binding TaskTitle}" Opacity="0.85"/>
|
||||||
|
<TextBlock>
|
||||||
|
<Run Text="{Binding DraftCount}"/>
|
||||||
|
<Run Text=" draft tasks waiting to be finalized."/>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||||
|
<Button Content="Discard" Command="{Binding DiscardCommand}"/>
|
||||||
|
<Button Content="Finalize now" Command="{Binding FinalizeNowCommand}"/>
|
||||||
|
<Button Content="Resume" Classes="accent" Command="{Binding ResumeCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Window>
|
||||||
|
```
|
||||||
|
|
||||||
|
`UnfinishedPlanningDialog.axaml.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Dialogs;
|
||||||
|
|
||||||
|
public partial class UnfinishedPlanningDialog : Window
|
||||||
|
{
|
||||||
|
public UnfinishedPlanningDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire into `TasksIslandViewModel`**
|
||||||
|
|
||||||
|
When the user right-clicks a `Planning` row OR when the app starts and a `Planning` row is present, show the dialog. Add a helper in the VM:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task<UnfinishedPlanningDialogResult> AskUnfinishedPlanningAsync(TaskRowViewModel row)
|
||||||
|
{
|
||||||
|
var dialogVm = new UnfinishedPlanningDialogViewModel
|
||||||
|
{
|
||||||
|
TaskTitle = row.Title,
|
||||||
|
DraftCount = await _workerClient.GetPendingDraftCountAsync(row.Id),
|
||||||
|
};
|
||||||
|
var dlg = new UnfinishedPlanningDialog { DataContext = dialogVm };
|
||||||
|
_ = dlg.ShowDialog(_ownerWindow);
|
||||||
|
return await dialogVm.Result.Task;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the direct resume/discard/finalize commands (from Task 3) with calls that first pop this dialog and dispatch based on result. For example:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || !row.IsPlanningParent) return;
|
||||||
|
var choice = await AskUnfinishedPlanningAsync(row);
|
||||||
|
switch (choice)
|
||||||
|
{
|
||||||
|
case UnfinishedPlanningDialogResult.Resume:
|
||||||
|
await _workerClient.ResumePlanningSessionAsync(row.Id);
|
||||||
|
break;
|
||||||
|
case UnfinishedPlanningDialogResult.FinalizeNow:
|
||||||
|
await _workerClient.FinalizePlanningSessionAsync(row.Id);
|
||||||
|
break;
|
||||||
|
case UnfinishedPlanningDialogResult.Discard:
|
||||||
|
await _workerClient.DiscardPlanningSessionAsync(row.Id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build + manual run**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: builds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml.cs src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
|
||||||
|
git commit -m "feat(ui): unfinished planning session dialog"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: TasksIslandView — wire new templates
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: No structural change required**
|
||||||
|
|
||||||
|
The hierarchy is already handled by `TasksIslandViewModel.RebuildFlatStreams` interleaving children into `OpenItems`/`CompletedItems`. The existing `ItemsControl` bindings in `TasksIslandView` automatically pick up the new rows. Indentation/chevron/badge rendering is entirely inside `TaskRowView` (Task 4).
|
||||||
|
|
||||||
|
Verify the view does not have any logic that filters out children based on `ParentTaskId IS NOT NULL` today. If it does, remove that filter — the VM is now authoritative about what's in the stream.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build + manual check**
|
||||||
|
|
||||||
|
Launch the UI, create a manual task, and manually update its status to `Planning` in the DB (or wait for Plan B). Create one child in DB. Verify indentation and chevron render.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit (if any change was made)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
|
||||||
|
git commit -m "chore(ui): verify tasks view renders hierarchy via flat stream"
|
||||||
|
```
|
||||||
|
|
||||||
|
If no change — skip the commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Delete-with-children handling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (existing delete command)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Catch `DbUpdateException` from delete**
|
||||||
|
|
||||||
|
Find the existing delete command. Wrap the repository/hub call:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RemoveAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _workerClient.DeleteTaskAsync(row.Id);
|
||||||
|
}
|
||||||
|
catch (HubException ex) when (ex.Message.Contains("foreign key", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| ex.Message.Contains("Restrict", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var childrenCount = 1; // or query via a new hub method if exact count matters
|
||||||
|
var choice = await _dialogs.ConfirmAsync(
|
||||||
|
"Cannot delete",
|
||||||
|
$"This task has child tasks. Delete all including children?");
|
||||||
|
if (!choice) return;
|
||||||
|
// Recursive delete — iterate children first. For v1 MVP, instruct user to
|
||||||
|
// discard the planning session first. Simpler, safer.
|
||||||
|
await _dialogs.ShowErrorAsync(
|
||||||
|
"Cannot delete",
|
||||||
|
"Discard the planning session or delete child tasks manually first.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Simplification for v1:** do not implement "Delete all including children" yet. Show an error instructing the user to discard the planning session or delete children first. This avoids an additional hub endpoint and keeps Plan C bounded.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: builds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
|
||||||
|
git commit -m "feat(ui): friendly error when deleting task with children"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Manual smoke test + final verification
|
||||||
|
|
||||||
|
**Files:** none
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full test run**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the full app**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||||
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
|
||||||
|
```
|
||||||
|
Expected: all succeed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual smoke test (requires Plan B merged)**
|
||||||
|
|
||||||
|
1. Launch the app.
|
||||||
|
2. Create a Manual task with a title and some TODO-style description.
|
||||||
|
3. Right-click → "Open planning Session".
|
||||||
|
4. Verify Windows Terminal opens with Claude CLI running.
|
||||||
|
5. In the terminal, ask Claude to create two child tasks (`mcp__claudedo__create_child_task`).
|
||||||
|
6. Watch the UI: drafts appear under the parent (italic, grey, badge DRAFT).
|
||||||
|
7. Ask Claude to `finalize`.
|
||||||
|
8. Verify drafts become Manual/Queued children, parent flips to PLANNED badge.
|
||||||
|
9. Close terminal without finalize on a new planning task; right-click the Planning task: dialog appears with Resume/Finalize/Discard.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Document any UI tweaks needed in `docs/open.md`**
|
||||||
|
|
||||||
|
Add a checklist item under UI verification for planning session visuals.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/open.md
|
||||||
|
git commit -m "docs(open): add planning-session manual verification checklist"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope for Plan C
|
||||||
|
|
||||||
|
- Recursive delete of parent-with-children via UI (error-only in v1).
|
||||||
|
- Collapse-state persistence across app restarts (in-memory only).
|
||||||
|
- Keyboard shortcut for "Open planning Session".
|
||||||
|
- Visual differentiation for PLANNED parents beyond a badge (e.g., subtle background tint) — can be added later if visually needed.
|
||||||
1831
docs/superpowers/plans/2026-04-23-self-update.md
Normal file
1831
docs/superpowers/plans/2026-04-23-self-update.md
Normal file
File diff suppressed because it is too large
Load Diff
718
docs/superpowers/plans/2026-04-23-worker-log-footer.md
Normal file
718
docs/superpowers/plans/2026-04-23-worker-log-footer.md
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
# Worker Log Footer Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Surface important Worker lifecycle events in the UI footer as a single rotating, color-coded line that auto-hides after 30s of silence.
|
||||||
|
|
||||||
|
**Architecture:** Add `WorkerLogLevel` enum in shared `ClaudeDo.Data` project. `HubBroadcaster` gets a `WorkerLog(message, level, timestampUtc)` SignalR event. Seven emit sites in `TaskRunner`, `TaskMergeService`, `TaskResetService` (callers of `WorktreeManager`, not WorktreeManager itself — they have the task title in scope). UI side: `WorkerClient` surfaces a `WorkerLogReceived` event; footer state lives on `IslandsShellViewModel` (existing root VM for `MainWindow`, also owns connection state); `System.Timers.Timer` clears the line after 30s; a `WorkerLogLevelToBrushConverter` maps level → brush in XAML.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, ASP.NET Core SignalR, Avalonia 12, CommunityToolkit.Mvvm, xUnit.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-23-worker-log-footer-design.md`
|
||||||
|
|
||||||
|
**Deviation from spec:** Spec names `WorktreeManager.CreateAsync` / `DiscardAsync` as emit sites. In practice, `WorktreeManager` has only the task ID in scope; its callers (`TaskRunner`, `TaskResetService`) have the title. Emitting from callers avoids adding constructor dependencies to `WorktreeManager` and produces identical user-visible behavior.
|
||||||
|
|
||||||
|
**Build note:** Per project convention, `dotnet build ClaudeDo.slnx` fails on .NET 8 — always build individual csprojs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add `WorkerLogLevel` enum (shared contract)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Data/Models/WorkerLogLevel.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the enum**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public enum WorkerLogLevel
|
||||||
|
{
|
||||||
|
Info,
|
||||||
|
Success,
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build Data project**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Models/WorkerLogLevel.cs
|
||||||
|
git commit -m "feat(data): add WorkerLogLevel enum"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add `WorkerLog` broadcaster method + SignalR JSON enum-as-string
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` (append method)
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Program.cs` (line ~23 — the `AddSignalR()` call)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add enum-as-string serialization**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
```
|
||||||
|
with:
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
||||||
|
{
|
||||||
|
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `WorkerLog` method to `HubBroadcaster`**
|
||||||
|
|
||||||
|
Add the following method inside `HubBroadcaster` class (after the existing `RunCreated` method):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
|
||||||
|
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to the using block at top of file (if not already present):
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Program.cs
|
||||||
|
git commit -m "feat(worker): add WorkerLog SignalR event"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Emit `WorkerLog` from `TaskRunner`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
|
||||||
|
|
||||||
|
Four emit sites in this file:
|
||||||
|
1. **Created worktree** — right after `_wtManager.CreateAsync` succeeds (around line 69).
|
||||||
|
2. **Started Claude** — just before invoking Claude process.
|
||||||
|
3. **Committed changes** — after auto-commit (before the `WorktreeUpdated` broadcast around line 318).
|
||||||
|
4. **Finished** — at both success (line 330) and failure paths, mirroring the existing `TaskFinished` call.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add using for `WorkerLogLevel`**
|
||||||
|
|
||||||
|
Ensure `TaskRunner.cs` has at the top:
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Emit "Created worktree"**
|
||||||
|
|
||||||
|
After the line `wtCtx = await _wtManager.CreateAsync(task, list, ct);` (around line 69), add:
|
||||||
|
```csharp
|
||||||
|
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
|
||||||
|
```
|
||||||
|
(Place inside the same `if` branch that called `CreateAsync`, after the assignment.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Emit "Started Claude"**
|
||||||
|
|
||||||
|
Locate the point just before `ClaudeProcess` is invoked (search for where `ClaudeProcess` or `RunProcessAsync` is called). Just before the invocation, add:
|
||||||
|
```csharp
|
||||||
|
await _broadcaster.WorkerLog($"Started Claude for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Emit "Committed changes"**
|
||||||
|
|
||||||
|
Locate the auto-commit code path (around line 318, just before `await _broadcaster.WorktreeUpdated(task.Id);`). Add immediately before that call:
|
||||||
|
```csharp
|
||||||
|
await _broadcaster.WorkerLog($"Committed changes in \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Emit "Finished (done)"**
|
||||||
|
|
||||||
|
Find the success finish path (around line 330, where `TaskFinished` is broadcast with status `"done"`). Add immediately before that call:
|
||||||
|
```csharp
|
||||||
|
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Emit "Finished (failed)"**
|
||||||
|
|
||||||
|
Find the failure path (search for `TaskFinished` with status `"failed"`). Add immediately before:
|
||||||
|
```csharp
|
||||||
|
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run existing Worker tests (no regressions)**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
|
||||||
|
git commit -m "feat(worker): emit WorkerLog events from TaskRunner"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Emit `WorkerLog` from `TaskMergeService` and `TaskResetService`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Services/TaskResetService.cs`
|
||||||
|
|
||||||
|
Both services already have `HubBroadcaster` injected (`_broadcaster`). Both already load the task entity (needed for title).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add using in both files**
|
||||||
|
|
||||||
|
Add to the top of each file (if not already present):
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Emit "Merged" in `TaskMergeService.MergeAsync`**
|
||||||
|
|
||||||
|
Locate the existing log line around line 137:
|
||||||
|
```csharp
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
|
||||||
|
...);
|
||||||
|
```
|
||||||
|
|
||||||
|
Immediately after it (before `return new MergeResult(...)` on line 140), add:
|
||||||
|
```csharp
|
||||||
|
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||||
|
```
|
||||||
|
Use whatever variable names `task` and `targetBranch` are in scope — adjust to match the actual local names at that site.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Emit "Discarded" in `TaskResetService.ResetAsync`**
|
||||||
|
|
||||||
|
Locate the call `await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);` (line 53). Immediately after it, add:
|
||||||
|
```csharp
|
||||||
|
await _broadcaster.WorkerLog($"Discarded worktree for \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Emit "Reset" in `TaskResetService.ResetAsync`**
|
||||||
|
|
||||||
|
Locate the existing line `_logger.LogInformation("Reset task {TaskId} to Manual ...` (line 66). Immediately after it, add:
|
||||||
|
```csharp
|
||||||
|
await _broadcaster.WorkerLog($"Reset \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run existing Worker tests**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Services/TaskMergeService.cs src/ClaudeDo.Worker/Services/TaskResetService.cs
|
||||||
|
git commit -m "feat(worker): emit WorkerLog for merge, discard, reset"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add `WorkerLogEntry` record + `WorkerLogReceived` event on `WorkerClient`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add using for `WorkerLogLevel`**
|
||||||
|
|
||||||
|
Add at the top of `WorkerClient.cs`:
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Declare the `WorkerLogEntry` record**
|
||||||
|
|
||||||
|
Add at the top of the file (above or below the `WorkerClient` class, same namespace):
|
||||||
|
```csharp
|
||||||
|
public sealed record WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the event field**
|
||||||
|
|
||||||
|
Alongside the other `public event Action<...>?` declarations (around lines 42-48), add:
|
||||||
|
```csharp
|
||||||
|
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Register the SignalR handler**
|
||||||
|
|
||||||
|
Alongside the other `_hub.On<...>` registrations (around lines 80-117), add:
|
||||||
|
```csharp
|
||||||
|
_hub.On<string, WorkerLogLevel, DateTime>("WorkerLog", (message, level, timestampUtc) =>
|
||||||
|
{
|
||||||
|
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
|
||||||
|
git commit -m "feat(ui): subscribe to WorkerLog SignalR event"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Create `ClaudeDo.Ui.Tests` project and add `WorkerLogLevelToBrushConverter`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/App.axaml` (register converter as resource)
|
||||||
|
- Create: `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
|
||||||
|
- Create: `tests/ClaudeDo.Ui.Tests/WorkerLogLevelToBrushConverterTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the converter**
|
||||||
|
|
||||||
|
Create `src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class WorkerLogLevelToBrushConverter : IValueConverter
|
||||||
|
{
|
||||||
|
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#4CAF50"));
|
||||||
|
private static readonly IBrush WarnBrush = new SolidColorBrush(Color.Parse("#FFA726"));
|
||||||
|
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#EF5350"));
|
||||||
|
private static readonly IBrush InfoFallback = new SolidColorBrush(Color.Parse("#888888"));
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is not WorkerLogLevel level)
|
||||||
|
return AvaloniaProperty.UnsetValue;
|
||||||
|
|
||||||
|
return level switch
|
||||||
|
{
|
||||||
|
WorkerLogLevel.Success => SuccessBrush,
|
||||||
|
WorkerLogLevel.Warn => WarnBrush,
|
||||||
|
WorkerLogLevel.Error => ErrorBrush,
|
||||||
|
WorkerLogLevel.Info => ResolveInfoBrush(),
|
||||||
|
_ => AvaloniaProperty.UnsetValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
private static IBrush ResolveInfoBrush()
|
||||||
|
{
|
||||||
|
if (Application.Current is { } app &&
|
||||||
|
app.Resources.TryGetResource("TextDimBrush", app.ActualThemeVariant, out var res) &&
|
||||||
|
res is IBrush brush)
|
||||||
|
{
|
||||||
|
return brush;
|
||||||
|
}
|
||||||
|
return InfoFallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register converter in `App.axaml`**
|
||||||
|
|
||||||
|
Open `src/ClaudeDo.Ui/App.axaml`. Inside the `<Application.Resources>` section (add one if missing), add alongside any existing converter entries:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure the `xmlns:converters="using:ClaudeDo.Ui.Converters"` namespace is declared at the root `<Application>` element. If other converters (e.g. `StatusColorConverter`) are already resources in `App.axaml` follow the same pattern; if they're declared per-view, declare this converter at the top of `MainWindow.axaml` in Task 8 instead.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create UI test project**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="12.0.0" />
|
||||||
|
<PackageReference Include="Avalonia.Headless" Version="12.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ClaudeDo.Ui\ClaudeDo.Ui.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the existing `tests/ClaudeDo.Worker.Tests/*.csproj` uses different `Microsoft.NET.Test.Sdk` / xUnit versions, match those versions exactly to avoid analyzer mismatches.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/WorkerLogLevelToBrushConverterTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Converters;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests;
|
||||||
|
|
||||||
|
public class WorkerLogLevelToBrushConverterTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(WorkerLogLevel.Success, "#FF4CAF50")]
|
||||||
|
[InlineData(WorkerLogLevel.Warn, "#FFFFA726")]
|
||||||
|
[InlineData(WorkerLogLevel.Error, "#FFEF5350")]
|
||||||
|
public void Convert_maps_level_to_expected_brush_color(WorkerLogLevel level, string expectedArgb)
|
||||||
|
{
|
||||||
|
var converter = new WorkerLogLevelToBrushConverter();
|
||||||
|
|
||||||
|
var result = converter.Convert(level, typeof(IBrush), null, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
var solid = Assert.IsType<SolidColorBrush>(result);
|
||||||
|
Assert.Equal(expectedArgb.ToLowerInvariant(), $"#{solid.Color.ToUInt32():X8}".ToLowerInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_info_returns_a_brush_fallback_when_no_app()
|
||||||
|
{
|
||||||
|
var converter = new WorkerLogLevelToBrushConverter();
|
||||||
|
|
||||||
|
var result = converter.Convert(WorkerLogLevel.Info, typeof(IBrush), null, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
Assert.IsAssignableFrom<IBrush>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_unknown_value_returns_unset()
|
||||||
|
{
|
||||||
|
var converter = new WorkerLogLevelToBrushConverter();
|
||||||
|
|
||||||
|
var result = converter.Convert("not a level", typeof(IBrush), null, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
Assert.Equal(AvaloniaProperty.UnsetValue, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the tests**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
|
||||||
|
Expected: All 5 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs src/ClaudeDo.Ui/App.axaml tests/ClaudeDo.Ui.Tests/
|
||||||
|
git commit -m "feat(ui): add WorkerLogLevelToBrushConverter with tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Add footer state + 30s auto-clear timer to `IslandsShellViewModel`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
||||||
|
- Create: `tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs`
|
||||||
|
|
||||||
|
Timer uses `System.Timers.Timer` (not `DispatcherTimer`) so unit tests don't need an Avalonia dispatcher. The elapsed callback marshals to the UI thread via `Dispatcher.UIThread.Post` when the dispatcher is available; in tests the VM logic under test sets properties directly so no marshalling is needed.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests;
|
||||||
|
|
||||||
|
public class IslandsShellViewModelWorkerLogTests
|
||||||
|
{
|
||||||
|
private static IslandsShellViewModel NewVm() =>
|
||||||
|
// The real constructor requires island VMs + WorkerClient. These tests
|
||||||
|
// only exercise the WorkerLog handling, so we use a test-only constructor
|
||||||
|
// that bypasses the sub-VMs. Add `internal IslandsShellViewModel()` for tests.
|
||||||
|
IslandsShellViewModel.CreateForTests();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Receiving_event_sets_text_level_and_visible()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var at = new DateTime(2026, 4, 23, 14, 32, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
vm.OnWorkerLogReceived(new WorkerLogEntry("Created worktree for \"X\"", WorkerLogLevel.Info, at));
|
||||||
|
|
||||||
|
Assert.True(vm.IsWorkerLogVisible);
|
||||||
|
Assert.Equal(WorkerLogLevel.Info, vm.WorkerLogLevel);
|
||||||
|
Assert.Contains("Created worktree for \"X\"", vm.WorkerLogText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Second_event_replaces_first()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
vm.OnWorkerLogReceived(new WorkerLogEntry("first", WorkerLogLevel.Info, DateTime.UtcNow));
|
||||||
|
vm.OnWorkerLogReceived(new WorkerLogEntry("second", WorkerLogLevel.Success, DateTime.UtcNow));
|
||||||
|
|
||||||
|
Assert.Contains("second", vm.WorkerLogText);
|
||||||
|
Assert.Equal(WorkerLogLevel.Success, vm.WorkerLogLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearWorkerLog_hides_line()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
vm.OnWorkerLogReceived(new WorkerLogEntry("msg", WorkerLogLevel.Info, DateTime.UtcNow));
|
||||||
|
|
||||||
|
vm.ClearWorkerLog();
|
||||||
|
|
||||||
|
Assert.False(vm.IsWorkerLogVisible);
|
||||||
|
Assert.Null(vm.WorkerLogText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Text_is_formatted_as_HHmm_dot_message_local_time()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var utc = new DateTime(2026, 4, 23, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
var expectedLocalHhmm = utc.ToLocalTime().ToString("HH:mm");
|
||||||
|
|
||||||
|
vm.OnWorkerLogReceived(new WorkerLogEntry("hello", WorkerLogLevel.Info, utc));
|
||||||
|
|
||||||
|
Assert.StartsWith(expectedLocalHhmm + " · ", vm.WorkerLogText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to confirm they fail to compile**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
|
||||||
|
Expected: Build errors — `CreateForTests`, `OnWorkerLogReceived`, `ClearWorkerLog`, `IsWorkerLogVisible`, `WorkerLogText`, `WorkerLogLevel` do not yet exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement on `IslandsShellViewModel`**
|
||||||
|
|
||||||
|
Open `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`. Add `using`s if missing:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Timers;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the class, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty] private string? workerLogText;
|
||||||
|
[ObservableProperty] private WorkerLogLevel workerLogLevel;
|
||||||
|
[ObservableProperty] private bool isWorkerLogVisible;
|
||||||
|
|
||||||
|
private readonly Timer _workerLogTimer = new(TimeSpan.FromSeconds(30).TotalMilliseconds)
|
||||||
|
{
|
||||||
|
AutoReset = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static IslandsShellViewModel CreateForTests() =>
|
||||||
|
(IslandsShellViewModel)System.Runtime.Serialization.FormatterServices
|
||||||
|
.GetUninitializedObject(typeof(IslandsShellViewModel));
|
||||||
|
```
|
||||||
|
|
||||||
|
(If `FormatterServices` is unavailable under `net8.0`, instead add a parameterless `internal IslandsShellViewModel() {}` constructor guarded for tests only.)
|
||||||
|
|
||||||
|
In the existing real constructor, wire up subscription (after the line `Worker.PropertyChanged += ...` block, around line 63-70):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
||||||
|
_workerLogTimer.Elapsed += (_, _) =>
|
||||||
|
{
|
||||||
|
if (Dispatcher.UIThread.CheckAccess()) ClearWorkerLog();
|
||||||
|
else Dispatcher.UIThread.Post(ClearWorkerLog);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the methods the tests call:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void OnWorkerLogReceived(WorkerLogEntry entry)
|
||||||
|
{
|
||||||
|
var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
|
||||||
|
WorkerLogText = $"{hhmm} · {entry.Message}";
|
||||||
|
WorkerLogLevel = entry.Level;
|
||||||
|
IsWorkerLogVisible = true;
|
||||||
|
|
||||||
|
_workerLogTimer.Stop();
|
||||||
|
_workerLogTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearWorkerLog()
|
||||||
|
{
|
||||||
|
IsWorkerLogVisible = false;
|
||||||
|
WorkerLogText = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
|
||||||
|
Expected: All tests pass (5 converter + 4 VM = 9 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build the UI project as a sanity check**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs
|
||||||
|
git commit -m "feat(ui): add worker log state and 30s timer to shell VM"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Update `MainWindow.axaml` footer — dock log line right
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml` (lines 104-135 — the footer `Border`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the converter resource to the window (if not already in App.axaml)**
|
||||||
|
|
||||||
|
If Task 6 declared the converter in `App.axaml`, skip this step. Otherwise, add a `<Window.Resources>` block near the top of `MainWindow.axaml`:
|
||||||
|
```xml
|
||||||
|
<Window.Resources>
|
||||||
|
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
|
||||||
|
</Window.Resources>
|
||||||
|
```
|
||||||
|
Ensure `xmlns:converters="using:ClaudeDo.Ui.Converters"` is declared on the root `<Window>`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the footer body**
|
||||||
|
|
||||||
|
Replace the existing footer `<Border Grid.Row="2" ...>` inner contents (the `<StackPanel>` at lines 109-134) with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<DockPanel LastChildFill="True" Margin="14,0">
|
||||||
|
<!-- Left: connection pill -->
|
||||||
|
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="7"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Ellipse Width="7" Height="7" Fill="#4CAF50"
|
||||||
|
IsVisible="{Binding Worker.IsConnected}"/>
|
||||||
|
<Ellipse Width="7" Height="7" Fill="#FFA726"
|
||||||
|
IsVisible="{Binding Worker.IsReconnecting}"/>
|
||||||
|
<Ellipse Width="7" Height="7" Fill="#EF5350"
|
||||||
|
IsVisible="{Binding IsOffline}"/>
|
||||||
|
<TextBlock Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="10"
|
||||||
|
LetterSpacing="1.4"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Right: worker log line -->
|
||||||
|
<TextBlock DockPanel.Dock="Right"
|
||||||
|
Text="{Binding WorkerLogText}"
|
||||||
|
IsVisible="{Binding IsWorkerLogVisible}"
|
||||||
|
Foreground="{Binding WorkerLogLevel, Converter={StaticResource WorkerLogLevelToBrush}}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="10"
|
||||||
|
LetterSpacing="1.4"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
|
||||||
|
<!-- Spacer (fills remaining space between pill and log) -->
|
||||||
|
<Panel/>
|
||||||
|
</DockPanel>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the UI**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: Build succeeded, 0 errors. No XAML compilation errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the full app (entry point)**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual smoke test**
|
||||||
|
|
||||||
|
Start the Worker and the App (two separate processes per CLAUDE.md).
|
||||||
|
|
||||||
|
Exercise each event and confirm the footer line appears with the expected color and copy:
|
||||||
|
|
||||||
|
1. Start a task → expect `HH:MM · Created worktree for "<title>"` (dim/info).
|
||||||
|
2. Observe while Claude runs → expect `HH:MM · Started Claude for "<title>"` (dim/info).
|
||||||
|
3. Task commits → expect `HH:MM · Committed changes in "<title>"` (dim/info).
|
||||||
|
4. Task finishes successfully → expect `HH:MM · Finished "<title>" (done)` (green).
|
||||||
|
5. Trigger a failing task → expect `HH:MM · Finished "<title>" (failed)` (red).
|
||||||
|
6. Reset a failed task → expect `HH:MM · Discarded worktree for "<title>"` (amber) followed by `HH:MM · Reset "<title>"` (amber).
|
||||||
|
7. Merge a completed task → expect `HH:MM · Merged "<title>" into <branch>` (green).
|
||||||
|
8. Wait 30s with no new events → footer log line disappears (connection pill remains).
|
||||||
|
9. Trigger a burst of 3 events in quick succession → only the most recent is shown; timer resets on each.
|
||||||
|
10. Long task title (≥60 chars) → line is ellipsized, connection pill on the left remains fully visible.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/MainWindow.axaml
|
||||||
|
git commit -m "feat(ui): show worker log line in footer"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- **Spec coverage:**
|
||||||
|
- Enum `WorkerLogLevel` in `ClaudeDo.Data` — Task 1 ✓
|
||||||
|
- SignalR enum-as-string — Task 2 ✓
|
||||||
|
- `HubBroadcaster.WorkerLog` — Task 2 ✓
|
||||||
|
- 7 emit sites with correct level mapping — Tasks 3, 4 ✓
|
||||||
|
- `WorkerClient.WorkerLogReceived` event + `WorkerLogEntry` record — Task 5 ✓
|
||||||
|
- `WorkerLogLevelToBrushConverter` with unit tests — Task 6 ✓
|
||||||
|
- Footer VM state + 30s timer + tests — Task 7 ✓
|
||||||
|
- Footer XAML (DockPanel, connection left, log right, level-based color, ellipsis) — Task 8 ✓
|
||||||
|
- Out-of-scope items (history drawer, filtering, persistence) — correctly omitted ✓
|
||||||
|
|
||||||
|
- **Placeholder scan:** No "TBD" / "handle edge cases" / "similar to Task N". All code is inline.
|
||||||
|
|
||||||
|
- **Type consistency:** `WorkerLogEntry(Message, Level, TimestampUtc)` — same signature used in Task 5 (declaration), Task 7 (consumer tests + VM). `WorkerLog(message, level, timestampUtc)` — same signature in Task 2 (broadcaster) and Tasks 3-4 (callers). `OnWorkerLogReceived` / `ClearWorkerLog` / `IsWorkerLogVisible` / `WorkerLogText` / `WorkerLogLevel` — consistent between Task 7 test and Task 7 implementation.
|
||||||
468
docs/superpowers/specs/2026-04-23-planning-sessions-design.md
Normal file
468
docs/superpowers/specs/2026-04-23-planning-sessions-design.md
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
# Planning Sessions — Design
|
||||||
|
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Date:** 2026-04-23
|
||||||
|
**Scope:** Feature — "Open planning Session" context menu on tasks that spawns an interactive Windows Terminal with Claude (Sonnet 4.6, medium thinking) and a scoped MCP server, letting the user brainstorm and have Claude break a rough task into concrete executable child-tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Allow a user to take a vague task (a title plus some TODO-style notes) and convert it — via interactive dialogue with Claude in a terminal — into a structured set of concrete, executable child-tasks that the worker queue can pick up and run.
|
||||||
|
|
||||||
|
The interaction is driven by Claude calling MCP tools against a scoped server running inside the existing `ClaudeDo.Worker` process. The parent task becomes a "Planning" container that holds its children as a flat (single-level) hierarchy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Status Flow
|
||||||
|
|
||||||
|
**Parent (new statuses `Planning`, `Planned`):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Manual ──[Open planning Session]──▶ Planning ──[finalize]──▶ Planned
|
||||||
|
│ │
|
||||||
|
│ (all children reach terminal state)
|
||||||
|
│ ▼
|
||||||
|
│ Done
|
||||||
|
│ or
|
||||||
|
│ Failed (if any child Failed)
|
||||||
|
▼
|
||||||
|
[Discard] ──▶ Manual
|
||||||
|
```
|
||||||
|
|
||||||
|
**Child (new status `Draft`):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Draft ──[finalize]──▶ Manual | Queued (if "agent" tag) ──▶ Running ──▶ Done | Failed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Parent with status `Planning` or `Planned` is **never** picked up by the queue.
|
||||||
|
- Children with status `Draft` are **never** picked up by the queue.
|
||||||
|
- Hierarchy is strictly **one level deep**: a child task cannot itself become a planning parent (enforced app-side: Plan menu item hidden/disabled if `ParentTaskId IS NOT NULL`).
|
||||||
|
- One planning session per parent task at a time (`StartPlanningSessionAsync` errors if parent is already `Planning`; use Resume instead).
|
||||||
|
- Parent auto-status on child completion (evaluated after any child reaches `Done` or `Failed`):
|
||||||
|
- At least one child `Failed` and no children still in non-terminal states → Parent `Failed`.
|
||||||
|
- All children `Done` → Parent `Done`.
|
||||||
|
- Any child still `Manual`/`Queued`/`Running`/`Draft` → Parent stays `Planned`.
|
||||||
|
- Worktree state (`Merged`/`Discarded`/`Kept`) is orthogonal; only `Task.Status` determines completion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Data Model
|
||||||
|
|
||||||
|
### 3.1 Schema changes to `Tasks` table
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ParentTaskId` | `string` (FK → `Tasks.Id`, `DeleteBehavior.Restrict`) | yes | When set, row is a child of a planning parent. NULL = top-level task. |
|
||||||
|
| `PlanningSessionId` | `string` | yes | Claude CLI session ID captured after first run; used with `--resume`. Only set on planning parents. |
|
||||||
|
| `PlanningSessionToken` | `string` | yes | Random 32-byte Base64 token generated per session; acts as bearer for MCP calls. NULL when no active session. |
|
||||||
|
| `PlanningFinalizedAt` | `DateTime` | yes | Timestamp when `finalize` was called. NULL until finalized. |
|
||||||
|
|
||||||
|
Index: `(ParentTaskId)` for fast children lookup.
|
||||||
|
|
||||||
|
### 3.2 Status enum additions
|
||||||
|
|
||||||
|
`ClaudeDo.Data.Models.TaskStatus` gains:
|
||||||
|
- `Planning` — parent, session active or paused, drafts may exist.
|
||||||
|
- `Planned` — parent, finalized, children are real tasks (may still be running).
|
||||||
|
- `Draft` — child, created during session, not yet finalized.
|
||||||
|
|
||||||
|
Existing values unchanged: `Manual | Queued | Running | Done | Failed`. Persisted via `ValueConverter` to string (existing convention — confirmed via `TaskEntity.cs`).
|
||||||
|
|
||||||
|
### 3.3 Navigation properties
|
||||||
|
|
||||||
|
On `TaskEntity`:
|
||||||
|
```csharp
|
||||||
|
public string? ParentTaskId { get; set; }
|
||||||
|
public TaskEntity? Parent { get; set; }
|
||||||
|
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
|
||||||
|
public string? PlanningSessionId { get; set; }
|
||||||
|
public string? PlanningSessionToken { get; set; }
|
||||||
|
public DateTime? PlanningFinalizedAt { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
In `TaskEntityConfiguration`:
|
||||||
|
```csharp
|
||||||
|
.HasOne(t => t.Parent)
|
||||||
|
.WithMany(t => t.Children)
|
||||||
|
.HasForeignKey(t => t.ParentTaskId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale for `Restrict`:** cascade delete would orphan worktrees of in-flight child tasks. UI must handle the `DbUpdateException` and prompt the user to discard children first.
|
||||||
|
|
||||||
|
### 3.4 Repository additions
|
||||||
|
|
||||||
|
`ITaskRepository` gains:
|
||||||
|
- `Task<IReadOnlyList<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct)`
|
||||||
|
- `Task<TaskEntity> CreateChildAsync(string parentId, string title, string? description, IReadOnlyList<string>? tagNames, string? commitType, CancellationToken ct)` — creates with `Status = Draft`, `ParentTaskId = parentId`.
|
||||||
|
- `Task<int> FinalizePlanningAsync(string parentId, bool queueAgentTasks, CancellationToken ct)` — transactional: all Drafts → `Manual` (or `Queued` if tagged "agent" and `queueAgentTasks=true`), parent → `Planned`, set `PlanningFinalizedAt`, clear `PlanningSessionToken`. Returns count of finalized children.
|
||||||
|
- `Task<bool> DiscardPlanningAsync(string parentId, CancellationToken ct)` — deletes all Drafts, parent → `Manual`, clears `PlanningSessionId/Token/FinalizedAt`.
|
||||||
|
- `Task<TaskEntity?> SetPlanningStartedAsync(string taskId, string sessionToken, CancellationToken ct)` — sets parent `Status = Planning`, stores token; returns null if parent not in `Manual` state.
|
||||||
|
- `Task UpdatePlanningSessionIdAsync(string parentId, string sessionId, CancellationToken ct)` — captures Claude CLI session ID after launch.
|
||||||
|
- `Task<TaskEntity?> FindByPlanningTokenAsync(string token, CancellationToken ct)` — used by MCP auth handler.
|
||||||
|
|
||||||
|
`GetNextQueuedAgentTaskAsync` — verify the existing query filters on `Status = Queued`; no additional filter needed since Planning/Planned/Draft are different statuses. Add explicit regression test.
|
||||||
|
|
||||||
|
### 3.5 Auto-status hook
|
||||||
|
|
||||||
|
After every `MarkDoneAsync`/`MarkFailedAsync` on a task with `ParentTaskId != null`, check parent children. If all in terminal state:
|
||||||
|
- Any `Failed` → parent `Failed` with `FinishedAt = now()`.
|
||||||
|
- All `Done` (or worktrees `Discarded`) → parent `Done` with `FinishedAt = now()`.
|
||||||
|
|
||||||
|
Implemented as a private helper `TryCompleteParentAsync(string parentId, CancellationToken ct)` called at the end of the two Mark methods.
|
||||||
|
|
||||||
|
### 3.6 Migration
|
||||||
|
|
||||||
|
`dotnet ef migrations add AddPlanningSupport` — adds four columns and the `(ParentTaskId)` index. No data migration needed (new columns all nullable).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. MCP Server Surface
|
||||||
|
|
||||||
|
### 4.1 Transport
|
||||||
|
|
||||||
|
**HTTP (streamable) inside the existing Worker Kestrel host.** Mount on `/mcp` alongside the existing SignalR hub at `127.0.0.1:47821`. No separate process, no stdio proxy.
|
||||||
|
|
||||||
|
Library: `ModelContextProtocol` (official C# MCP SDK).
|
||||||
|
|
||||||
|
### 4.2 Authentication
|
||||||
|
|
||||||
|
Per-session bearer token:
|
||||||
|
1. `StartPlanningSessionAsync` generates a 32-byte random token, persists to `Tasks.PlanningSessionToken`.
|
||||||
|
2. Token is written into the session's `mcp.json` as `Authorization: Bearer <token>`.
|
||||||
|
3. Every MCP request passes through an auth filter that looks up the token via `FindByPlanningTokenAsync`. If found, the parent task ID is stored in the request context. If not, 401.
|
||||||
|
4. Token is invalidated (set NULL) on `finalize` or `discard`.
|
||||||
|
|
||||||
|
### 4.3 Tools
|
||||||
|
|
||||||
|
All tools are scoped to the parent task resolved from the request's token. `parent_id` is never an argument.
|
||||||
|
|
||||||
|
| Tool | Params | Returns | Effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `create_child_task` | `title: string`, `description?: string`, `tags?: string[]`, `commit_type?: string` | `{ task_id, status: "Draft" }` | Creates a Draft child under this session's parent. |
|
||||||
|
| `list_child_tasks` | — | `[{ task_id, title, description, status, tags }]` | Lists children of this parent (in session context, always Drafts). |
|
||||||
|
| `update_child_task` | `task_id: string`, optional: `title`, `description`, `tags`, `commit_type` | `{ task }` | Errors if target is not a Draft or not a child of this parent. |
|
||||||
|
| `delete_child_task` | `task_id: string` | `{ ok: true }` | Errors if target is not a Draft or not a child of this parent. |
|
||||||
|
| `update_planning_task` | `title?: string`, `description?: string` | `{ task }` | Only title/description on the parent itself. |
|
||||||
|
| `finalize` | `queue_agent_tasks?: bool = true` | `{ finalized_count: int }` | Calls `FinalizePlanningAsync`. Token invalidated. |
|
||||||
|
|
||||||
|
### 4.4 Real-time UI
|
||||||
|
|
||||||
|
After each successful tool call, the MCP handler fires a `TaskUpdated` event on the Worker's SignalR hub. The UI subscribes as it already does; drafts appear/update live in the tasks list while the user chats with Claude in the terminal.
|
||||||
|
|
||||||
|
### 4.5 Errors
|
||||||
|
|
||||||
|
- 401 for missing/invalid token.
|
||||||
|
- MCP error `-32602` "task not found or not a child of this planning session" for cross-parent access attempts.
|
||||||
|
- MCP error `-32602` "cannot modify finalized task" for `update/delete` on non-Draft.
|
||||||
|
- Token validation short-circuits before tool dispatch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Terminal Launch & Claude CLI Invocation
|
||||||
|
|
||||||
|
### 5.1 Launcher service
|
||||||
|
|
||||||
|
New interface `IPlanningTerminalLauncher` in the UI or App layer:
|
||||||
|
```csharp
|
||||||
|
Task LaunchAsync(PlanningSessionStart info, CancellationToken ct);
|
||||||
|
Task LaunchResumeAsync(PlanningSessionResume info, CancellationToken ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
`PlanningSessionStart` contains: `WorkingDir`, `McpConfigPath`, `InitialPromptPath`, `SystemPromptPath`.
|
||||||
|
`PlanningSessionResume` contains: `WorkingDir`, `McpConfigPath`, `ClaudeSessionId`.
|
||||||
|
|
||||||
|
### 5.2 Per-session files
|
||||||
|
|
||||||
|
Path: `~/.todo-app/planning-sessions/<parentTaskId>/`
|
||||||
|
- `mcp.json` — MCP config referencing the HTTP endpoint with bearer token.
|
||||||
|
- `system-prompt.md` — planning-mode system prompt (append, not replace).
|
||||||
|
- `initial-prompt.txt` — first user-visible message (title + description + short instructions).
|
||||||
|
|
||||||
|
Cleanup:
|
||||||
|
- `Discard` → remove directory.
|
||||||
|
- `Finalize` → keep directory (for audit; prune on app start if older than N days, optional).
|
||||||
|
|
||||||
|
### 5.3 `mcp.json` format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"claudedo": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://127.0.0.1:47821/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer <PlanningSessionToken>" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Claude CLI invocation (new session)
|
||||||
|
|
||||||
|
```
|
||||||
|
wt.exe -d "<list.WorkingDir>" cmd /k ^
|
||||||
|
claude ^
|
||||||
|
--model claude-sonnet-4-6 ^
|
||||||
|
--append-system-prompt "<contents of system-prompt.md>" ^
|
||||||
|
--mcp-config "<mcp.json absolute path>" ^
|
||||||
|
--allowedTools "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill" ^
|
||||||
|
"<contents of initial-prompt.txt>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Claude CLI invocation (resume)
|
||||||
|
|
||||||
|
```
|
||||||
|
wt.exe -d "<list.WorkingDir>" cmd /k ^
|
||||||
|
claude --resume <PlanningSessionId> --mcp-config "<mcp.json>"
|
||||||
|
```
|
||||||
|
Resume inherits model, system prompt, and allowed tools from the original session.
|
||||||
|
|
||||||
|
### 5.6 System prompt (draft, refined in Plan B)
|
||||||
|
|
||||||
|
> You are in a ClaudeDo planning session for a task. Your job is to brainstorm with the user, then break their rough intent into concrete, independently-executable child-tasks. Each child-task should be something a single automated agent can pick up and complete autonomously. Use the `mcp__claudedo__*` tools to create/update/delete drafts in real time. You may read the repository for context (Read/Grep/Glob) but must not modify any files. When the user is satisfied, call `finalize`. Skills you may find useful: `superpowers:writing-plans`, `superpowers:writing-clearly-and-concisely`.
|
||||||
|
|
||||||
|
### 5.7 Initial prompt (template)
|
||||||
|
|
||||||
|
```
|
||||||
|
<Parent task title>
|
||||||
|
|
||||||
|
<Parent task description, if any>
|
||||||
|
|
||||||
|
---
|
||||||
|
We're planning this task together. Brainstorm with me, then create concrete child-tasks via the MCP tools. I'll call `finalize` when we're done.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.8 Unknowns to resolve during Plan B implementation
|
||||||
|
|
||||||
|
These are left **open** in this spec; they'll be pinned down during implementation via `mcp__plugin_context7_context7__query-docs` for the Claude Code CLI:
|
||||||
|
|
||||||
|
1. Exact flag for thinking budget (`--thinking-budget medium`? model suffix `claude-sonnet-4-6-thinking`? something else?).
|
||||||
|
2. Exact casing of tool names in `--allowedTools` (`Read`/`read`, `WebFetch`/`web_fetch`, `Skill`).
|
||||||
|
3. Whether `--append-system-prompt` accepts a file reference (`@path`) or requires inline string.
|
||||||
|
4. Whether Claude CLI supports a `--session-id` flag for pre-assigning the session ID, or whether we must read it back from `~/.claude/projects/<hash>/sessions/` after the process starts.
|
||||||
|
|
||||||
|
If (4) resolves to "read back", strategy:
|
||||||
|
- Poll `~/.claude/projects/<hash>/sessions/` directory modtimes shortly after launch; newest session file after launch timestamp is ours.
|
||||||
|
- Cache the result on the parent task via `UpdatePlanningSessionIdAsync`.
|
||||||
|
- If session ID can't be captured, Resume falls back to `claude --continue` (last session in that project).
|
||||||
|
|
||||||
|
### 5.9 Pre-flight checks
|
||||||
|
|
||||||
|
On `LaunchAsync`:
|
||||||
|
- `wt.exe` resolvable in PATH → else throw `PlanningLaunchException("Windows Terminal not found")`, UI shows install hint.
|
||||||
|
- `claude` resolvable in PATH → else `PlanningLaunchException("Claude CLI not installed")`.
|
||||||
|
- `list.WorkingDir` exists → else `PlanningLaunchException("Working directory not found: <path>")`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UI Changes
|
||||||
|
|
||||||
|
### 6.1 Context menu (`TaskRowView.axaml`)
|
||||||
|
|
||||||
|
New entries, conditional on status:
|
||||||
|
- `Manual` + `ParentTaskId IS NULL` → **"Open planning Session"**
|
||||||
|
- `Planning` → **"Resume planning Session"** and **"Discard planning session"**
|
||||||
|
- `Planned` / `Done` / `Failed` (parent) → no planning-related entries (7c: no re-planning).
|
||||||
|
- Children (`ParentTaskId IS NOT NULL`) → never show planning entries.
|
||||||
|
|
||||||
|
### 6.2 Hierarchy rendering (`TasksIslandView.axaml`)
|
||||||
|
|
||||||
|
Approach: **flat stream with indentation**, not a `TreeView`.
|
||||||
|
|
||||||
|
- `TasksIslandViewModel` builds `OpenItems`/`CompletedItems`/etc. as flat `ObservableCollection<TaskRowViewModel>` with parents followed by their children if expanded.
|
||||||
|
- `TaskRowViewModel` gets `IsChild: bool` and `IsPlanningParent: bool` and `IsExpanded: bool`.
|
||||||
|
- `TaskRowView` indents 24px when `IsChild`, shows a thin left border in `TextFaintBrush`.
|
||||||
|
- Parents with `IsPlanningParent` render a chevron (▸/▾) that toggles `IsExpanded`; collapsed parents hide their children from the flat stream.
|
||||||
|
- Expanded-state map kept in the VM (`Dictionary<string, bool>`, default `true`).
|
||||||
|
|
||||||
|
### 6.3 Draft and planning styling (`TaskRowView`)
|
||||||
|
|
||||||
|
- `Status = Draft` → row italic, 70% opacity, small left-aligned badge "DRAFT".
|
||||||
|
- Parent `Status = Planning` → badge "PLANNING" (accent: warning-amber).
|
||||||
|
- Parent `Status = Planned` → badge "PLANNED" (accent: neutral-blue).
|
||||||
|
|
||||||
|
### 6.4 Unfinished-session dialog
|
||||||
|
|
||||||
|
Trigger: on app start **and** on any context-menu click against a `Planning` parent.
|
||||||
|
|
||||||
|
Modal (built with existing `TaskCompletionSource<T>` dialog pattern):
|
||||||
|
|
||||||
|
```
|
||||||
|
Unfinished planning session
|
||||||
|
"<Parent title>"
|
||||||
|
<N> draft tasks waiting to be finalized.
|
||||||
|
|
||||||
|
[Resume] [Finalize now] [Discard]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Resume → `ResumePlanningSessionAsync`, opens terminal with `--resume`.
|
||||||
|
- Finalize now → `FinalizePlanningSessionAsync` (server-side, no terminal). Useful when the user is confident drafts are good.
|
||||||
|
- Discard → `DiscardPlanningSessionAsync`.
|
||||||
|
|
||||||
|
### 6.5 TasksIslandViewModel commands
|
||||||
|
|
||||||
|
- `[RelayCommand] OpenPlanningSessionAsync(TaskRowViewModel? row)`
|
||||||
|
- `[RelayCommand] ResumePlanningSessionAsync(TaskRowViewModel? row)`
|
||||||
|
- `[RelayCommand] DiscardPlanningSessionAsync(TaskRowViewModel? row)`
|
||||||
|
- `[RelayCommand] FinalizePlanningSessionAsync(TaskRowViewModel? row)`
|
||||||
|
- `[RelayCommand] ToggleExpand(TaskRowViewModel parentRow)`
|
||||||
|
|
||||||
|
### 6.6 WorkerClient additions (`ClaudeDo.Ui/Services/WorkerClient.cs`)
|
||||||
|
|
||||||
|
- `Task<PlanningSessionLaunchInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct)` — returns `{ WorkingDir, McpConfigPath, InitialPromptPath, SystemPromptPath }`.
|
||||||
|
- `Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct)` — returns `{ WorkingDir, McpConfigPath, ClaudeSessionId }`.
|
||||||
|
- `Task<int> FinalizePlanningSessionAsync(string taskId, CancellationToken ct)` — returns finalized count.
|
||||||
|
- `Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct)`.
|
||||||
|
- `Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct)` — for the unfinished-session dialog.
|
||||||
|
|
||||||
|
Existing `TaskUpdated` event covers live draft updates; no new event needed.
|
||||||
|
|
||||||
|
### 6.7 Delete handling
|
||||||
|
|
||||||
|
When the user tries to delete a parent with children:
|
||||||
|
- Repository throws `DbUpdateException` (FK Restrict).
|
||||||
|
- UI catches, shows: "This task has N child tasks. Discard drafts and delete? / Delete all including children? / Cancel."
|
||||||
|
- "Delete all including children" → UI iterates children and deletes them first, then the parent.
|
||||||
|
- "Discard drafts" option only appears if parent status is `Planning` (drafts exist to discard).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Lifecycle & Error Handling
|
||||||
|
|
||||||
|
### 7.1 Worker queue isolation
|
||||||
|
|
||||||
|
`GetNextQueuedAgentTaskAsync` filters on `Status = Queued` — Planning/Planned/Draft are excluded by status. Add explicit regression test to lock this in.
|
||||||
|
|
||||||
|
### 7.2 Parent auto-completion (repeat of 2, for implementation reference)
|
||||||
|
|
||||||
|
After `MarkDoneAsync`/`MarkFailedAsync`:
|
||||||
|
```csharp
|
||||||
|
if (task.ParentTaskId is not null)
|
||||||
|
await TryCompleteParentAsync(task.ParentTaskId, ct);
|
||||||
|
```
|
||||||
|
where `TryCompleteParentAsync` loads children, checks terminal status, sets parent accordingly.
|
||||||
|
|
||||||
|
### 7.3 Session-start errors
|
||||||
|
|
||||||
|
Table in §5.9 Pre-flight checks. UI receives typed exceptions, shows appropriate dialog.
|
||||||
|
|
||||||
|
### 7.4 Session-runtime errors
|
||||||
|
|
||||||
|
- Terminal crashes → drafts + token persist. Resume via dialog (§6.4).
|
||||||
|
- Worker restart → drafts + token persist. Resume rebuilds HTTP connection.
|
||||||
|
- MCP call fails transiently → Claude CLI retries or the model reports the error to the user in terminal; drafts remain in whatever state the last successful call left them.
|
||||||
|
- No session timeout — brainstorming may be long.
|
||||||
|
|
||||||
|
### 7.5 Concurrency
|
||||||
|
|
||||||
|
- Different parents → independent sessions, one token per parent.
|
||||||
|
- Same parent launched twice → `StartPlanningSessionAsync` throws; UI says "Already planning; use Resume".
|
||||||
|
- Cleanup on app exit: nothing — planning state is fully persisted in DB and files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Testing
|
||||||
|
|
||||||
|
### 8.1 Automated (in `ClaudeDo.Worker.Tests`)
|
||||||
|
|
||||||
|
**Schema & repository:**
|
||||||
|
- Migration applies cleanly on fresh DB.
|
||||||
|
- `GetChildrenAsync` returns only direct children, sorted.
|
||||||
|
- `CreateChildAsync` sets Status=Draft, ParentTaskId correctly.
|
||||||
|
- `FinalizePlanningAsync` transactionally transitions drafts to Manual/Queued, sets parent to Planned, sets timestamp, clears token. On simulated DB error, rolls back fully.
|
||||||
|
- `DiscardPlanningAsync` removes drafts, resets parent.
|
||||||
|
- `GetNextQueuedAgentTaskAsync` ignores Drafts, Planning parents, Planned parents.
|
||||||
|
- `Restrict` cascade: delete parent with children throws `DbUpdateException`.
|
||||||
|
|
||||||
|
**Auto-status hook (§7.2):**
|
||||||
|
- All children Done → parent Done.
|
||||||
|
- Mix: some Done, at least one Failed, rest in terminal state → parent Failed.
|
||||||
|
- Mix with one still Running → parent stays Planned.
|
||||||
|
- Parent stays Planned while any Draft exists (defensive — finalize should have cleared them).
|
||||||
|
|
||||||
|
**MCP handlers (against SQLite + in-process HTTP):**
|
||||||
|
- Valid token → tool executes.
|
||||||
|
- Missing/invalid token → 401.
|
||||||
|
- `create_child_task` → creates Draft, emits TaskUpdated event.
|
||||||
|
- `update_child_task` on non-Draft → MCP error.
|
||||||
|
- `delete_child_task` on non-Draft → MCP error.
|
||||||
|
- `finalize` called twice: first succeeds, second errors because token is invalidated.
|
||||||
|
- Cross-parent access: tool with `task_id` belonging to another parent's session → MCP error.
|
||||||
|
|
||||||
|
**SignalR endpoints (integration with Worker host):**
|
||||||
|
- Start → token generated, session directory + files created, `mcp.json` contains token.
|
||||||
|
- Start on already-`Planning` parent → error.
|
||||||
|
- Resume → no new token, reads `PlanningSessionId` from DB.
|
||||||
|
- Discard → drafts gone, directory removed, token NULL, parent back to Manual.
|
||||||
|
|
||||||
|
### 8.2 Manual (added to `docs/open.md` checklist)
|
||||||
|
|
||||||
|
- Windows Terminal spawn with real `wt.exe`.
|
||||||
|
- Real Claude CLI end-to-end session (requires `ANTHROPIC_API_KEY`).
|
||||||
|
- Avalonia hierarchy rendering (chevron, indentation, draft styling, badges).
|
||||||
|
- Session-ID capture from `~/.claude/projects/...` (timing-sensitive, platform-specific).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phasing
|
||||||
|
|
||||||
|
Work is delivered in **three sequential-then-parallel** plans. Plan A must merge before B and C can merge.
|
||||||
|
|
||||||
|
### 9.1 Plan A — Foundation
|
||||||
|
|
||||||
|
Schema migration, enum additions, repository methods, auto-status hook, delete-Restrict, regression test for queue filter. No UI-visible changes (other than delete-with-children now failing with a generic error until Plan C handles it).
|
||||||
|
|
||||||
|
Scope files (approximate):
|
||||||
|
- `src/ClaudeDo.Data/Models/TaskEntity.cs`
|
||||||
|
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`
|
||||||
|
- `src/ClaudeDo.Data/Migrations/<new>_AddPlanningSupport.cs`
|
||||||
|
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (+ `ITaskRepository`)
|
||||||
|
- `src/ClaudeDo.Worker/...` auto-status hook call-site updates.
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/...` new test classes.
|
||||||
|
|
||||||
|
### 9.2 Plan B — Worker MCP + SignalR + Launcher (starts after A merges)
|
||||||
|
|
||||||
|
MCP service with HTTP transport, token auth, six tools. New SignalR hub endpoints for Start/Resume/Discard/Finalize/GetPendingDraftCount. Session directory management. `IPlanningTerminalLauncher` implementation for `wt.exe`. Resolves the four unknowns from §5.8.
|
||||||
|
|
||||||
|
Scope files (approximate):
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningMcpService.cs` (new)
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (new)
|
||||||
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (extend)
|
||||||
|
- `src/ClaudeDo.Worker/Program.cs` (DI + endpoint mapping)
|
||||||
|
- `src/ClaudeDo.App/` or `src/ClaudeDo.Ui/Services/` — `IPlanningTerminalLauncher` + `WindowsTerminalPlanningLauncher`.
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Planning/...`
|
||||||
|
|
||||||
|
### 9.3 Plan C — UI (parallel to B after A merges)
|
||||||
|
|
||||||
|
Context menu entries, hierarchy rendering, draft styling, unfinished-session dialog, WorkerClient extensions, delete-with-children handling. During parallel development, mocks the WorkerClient against Plan B's interface contract.
|
||||||
|
|
||||||
|
Scope files (approximate):
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (draft/badge styling)
|
||||||
|
- `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` (new)
|
||||||
|
|
||||||
|
### 9.4 Integration points between B and C
|
||||||
|
|
||||||
|
Interface contract locked before parallel work begins:
|
||||||
|
- SignalR method names, parameters, return DTOs (listed in §6.6).
|
||||||
|
- `TaskUpdated` event payload unchanged; carries the task's new parent-id and status so the UI can re-bucket.
|
||||||
|
- Session directory path shape: `~/.todo-app/planning-sessions/<parentTaskId>/`.
|
||||||
|
- `mcp.json` and session-file formats are internal to Plan B; UI never reads them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Out of scope (for now)
|
||||||
|
|
||||||
|
- Nested planning (children of children). Explicitly one level.
|
||||||
|
- Cross-list planning (parent in list A, children in list B).
|
||||||
|
- Multi-user collaboration on the same planning session.
|
||||||
|
- Session timeouts / auto-discard.
|
||||||
|
- Planning-session history / audit UI. Directory is kept on finalize but not surfaced.
|
||||||
|
- Re-planning a finalized parent (7c: no).
|
||||||
230
docs/superpowers/specs/2026-04-23-self-update-design.md
Normal file
230
docs/superpowers/specs/2026-04-23-self-update-design.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# Self-Update for App and Installer — Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-23
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
Give ClaudeDo two update paths:
|
||||||
|
|
||||||
|
- **A — App-side update check:** the Avalonia UI checks Gitea for a newer release on startup (and via a manual menu action) and surfaces a dismissible banner. Clicking **Update now** launches the locally installed installer in Update mode and closes the UI.
|
||||||
|
- **B — Installer self-update:** the WPF installer checks for a newer installer binary on launch and offers to replace itself before continuing. After replacement, it proceeds with its normal wizard.
|
||||||
|
|
||||||
|
Non-goals:
|
||||||
|
|
||||||
|
- No silent/background auto-apply of updates. The user always initiates the final update action.
|
||||||
|
- No periodic in-app polling (startup + manual only).
|
||||||
|
- No changes to the release pipeline — release assets (`ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, `checksums.txt`) stay as they are.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
A new shared library, `ClaudeDo.Releases`, hosts the release-API client, version comparison, checksum verification, and installer self-update logic. Both `ClaudeDo.Installer` and `ClaudeDo.Ui` reference it. This removes the existing duplication between the installer's release plumbing and what the app needs, and keeps a single asset-matching / version-parsing code path.
|
||||||
|
|
||||||
|
```
|
||||||
|
ClaudeDo.Releases (new, netstandard2.0 or net8.0)
|
||||||
|
├── ReleaseClient.cs (moved from Installer/Core)
|
||||||
|
├── IReleaseClient.cs (moved)
|
||||||
|
├── ChecksumVerifier.cs (moved)
|
||||||
|
├── VersionComparer.cs (new)
|
||||||
|
└── SelfUpdater.cs (new — installer self-update mechanism)
|
||||||
|
|
||||||
|
ClaudeDo.Installer (WPF, consumes ClaudeDo.Releases)
|
||||||
|
├── App.xaml.cs (modified — SelfUpdater + --replace-self arg)
|
||||||
|
└── Core/InstallModeDetector.cs (modified — now uses VersionComparer)
|
||||||
|
|
||||||
|
ClaudeDo.Ui (Avalonia, consumes ClaudeDo.Releases)
|
||||||
|
├── Services/UpdateCheckService.cs (new)
|
||||||
|
├── Services/InstallerLocator.cs (new)
|
||||||
|
├── ViewModels/MainViewModel.cs (modified — banner + Help menu state)
|
||||||
|
└── Views/MainWindow.axaml (modified — banner + Help dropdown)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Part A — App-Side Update Check
|
||||||
|
|
||||||
|
### `ClaudeDo.Ui/Services/UpdateCheckService.cs`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Read the current app version from `Assembly.GetExecutingAssembly().GetName().Version`.
|
||||||
|
- Call `IReleaseClient.GetLatestReleaseAsync` to fetch the Gitea release.
|
||||||
|
- Use `VersionComparer` to decide whether the latest is newer.
|
||||||
|
- Expose observable properties: `IsUpdateAvailable`, `LatestVersion`, `CurrentVersion`, `IsChecking`, `LastCheckStatus` (`UpToDate | UpdateAvailable | CheckFailed | NeverChecked`).
|
||||||
|
- Expose a `CheckNowAsync` method for the manual Help menu action.
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
|
||||||
|
- Registered as a singleton in DI.
|
||||||
|
- Startup check is fired from `MainViewModel` once the main window is shown. It runs fire-and-forget on a background `Task`; UI never blocks on it.
|
||||||
|
- Manual check is awaited by its command and briefly shows a status message (see UI).
|
||||||
|
|
||||||
|
Error handling:
|
||||||
|
|
||||||
|
- Network / API errors → log to `~/.todo-app/logs/`, set `LastCheckStatus = CheckFailed`, do not surface a banner. Manual check shows a small inline status only.
|
||||||
|
|
||||||
|
### `ClaudeDo.Ui/Services/InstallerLocator.cs`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Resolve the path to the installed `ClaudeDo.Installer.exe` so the UI can launch it.
|
||||||
|
|
||||||
|
Discovery strategy (first hit wins):
|
||||||
|
|
||||||
|
1. Walk up from `AppContext.BaseDirectory` looking for a sibling `install.json`. The installer is at `{installDir}/uninstaller/ClaudeDo.Installer.exe`.
|
||||||
|
2. Fall back to reading `HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo\InstallLocation` (written by the existing `WriteUninstallRegistryStep`).
|
||||||
|
|
||||||
|
If neither yields a valid path, the banner's **Update now** button is disabled with a tooltip explaining the installer could not be located.
|
||||||
|
|
||||||
|
### UI changes
|
||||||
|
|
||||||
|
**Banner** at the top of `MainView` (above content, below custom chrome):
|
||||||
|
|
||||||
|
- Visible when `UpdateCheckService.IsUpdateAvailable == true` and the user has not dismissed this session.
|
||||||
|
- Text: `Update available: v{CurrentVersion} → v{LatestVersion}`.
|
||||||
|
- Actions: `Update now` (primary), `Dismiss` (sets a transient `IsBannerDismissed` flag that resets on app restart — intentionally not persisted so the banner returns next launch if still relevant).
|
||||||
|
- Styled to match existing chrome/accent conventions (compact, dismissible, non-modal).
|
||||||
|
|
||||||
|
**Help dropdown** in the custom titlebar:
|
||||||
|
|
||||||
|
- New menu: `Help`.
|
||||||
|
- First item: `Check for updates` → binds to `MainViewModel.CheckForUpdatesCommand`, which calls `UpdateCheckService.CheckNowAsync`.
|
||||||
|
- When a check completes:
|
||||||
|
- `UpdateAvailable` → banner appears (no separate dialog).
|
||||||
|
- `UpToDate` → a brief inline status in the banner area: `You're up to date (v{CurrentVersion})`, auto-hides after ~3 seconds.
|
||||||
|
- `CheckFailed` → `Could not check for updates` inline message, auto-hides after ~3 seconds.
|
||||||
|
- Leaves room for future items (`About`, `Documentation`, etc.).
|
||||||
|
|
||||||
|
### Update action flow
|
||||||
|
|
||||||
|
1. User clicks **Update now** in the banner.
|
||||||
|
2. `MainViewModel` resolves the installer path via `InstallerLocator`.
|
||||||
|
3. UI spawns `ClaudeDo.Installer.exe` with no arguments. The installer's existing `InstallModeDetector` reads `install.json` alongside it, hits Gitea, and enters `Update` mode.
|
||||||
|
4. UI closes itself immediately after spawning the process.
|
||||||
|
5. The installer performs its standard update flow: `StopServiceStep` → `DownloadAndExtractStep` → `StartServiceStep`.
|
||||||
|
6. When the user clicks Finish the installer exits. The user re-launches the app via their existing shortcut.
|
||||||
|
|
||||||
|
No IPC between UI and installer is needed — the installer is already self-sufficient once launched against an existing install directory.
|
||||||
|
|
||||||
|
## Part B — Installer Self-Update
|
||||||
|
|
||||||
|
### `ClaudeDo.Releases/SelfUpdater.cs`
|
||||||
|
|
||||||
|
Runs from `ClaudeDo.Installer/App.xaml.cs` before any window is shown.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. **Handle `--replace-self` argument first.** If the installer was launched with `--replace-self "<old-path>"`:
|
||||||
|
- Wait up to 5 seconds for the old process to exit (poll for file lock release).
|
||||||
|
- Delete `<old-path>`.
|
||||||
|
- Copy own exe to `<old-path>`.
|
||||||
|
- Start a new process at `<old-path>` with no args, then exit the current (temp) process. This ensures the user's shortcut or Apps & Features entry now points at the updated binary.
|
||||||
|
- If any step fails, fall through to the normal wizard (the user still has a working installer, just in a temp location).
|
||||||
|
|
||||||
|
2. **Check for a newer installer.** If no `--replace-self` arg:
|
||||||
|
- Parse own assembly version.
|
||||||
|
- Fetch latest release.
|
||||||
|
- Find the installer asset matching `ClaudeDo.Installer-<version>.exe` (regex: `^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$`).
|
||||||
|
- Compare via `VersionComparer`.
|
||||||
|
- If not newer, or if check fails, proceed to the normal wizard (existing Config-mode fallback behavior).
|
||||||
|
|
||||||
|
3. **Prompt if newer.** Show a small modal dialog (plain WPF `Window`, reusing the installer's titlebar/accent styles):
|
||||||
|
> *"A newer installer is available: v{latest}. Update before continuing?"*
|
||||||
|
> `[Update] [Continue anyway] [Cancel]`
|
||||||
|
|
||||||
|
- **Cancel** → `Application.Current.Shutdown(0)`.
|
||||||
|
- **Continue anyway** → proceed to normal wizard.
|
||||||
|
- **Update** → run relaunch sequence.
|
||||||
|
|
||||||
|
4. **Relaunch sequence:**
|
||||||
|
- Download to `%TEMP%\ClaudeDo.Installer-<version>.exe` (show a minimal inline progress UI; no separate window).
|
||||||
|
- Verify against `checksums.txt` from the release via `ChecksumVerifier`. On failure, show error with `[Continue with current installer]` action → proceed to wizard.
|
||||||
|
- `Process.Start` new exe with args `--replace-self "<current-exe-path>"`.
|
||||||
|
- Exit current process.
|
||||||
|
|
||||||
|
### Why `--replace-self` rather than a shell script
|
||||||
|
|
||||||
|
A child process holding a handle to the new exe is reliable cross-Windows-version. Relying on a `.bat` or `cmd /c` helper leaves a file that we would need to clean up, and behaves badly when the installer was launched from a mounted share or non-ASCII path. The `--replace-self` approach keeps everything in managed code and uses a single exe throughout.
|
||||||
|
|
||||||
|
### Edge case: running from `uninstaller/` copy
|
||||||
|
|
||||||
|
When the installer runs from `{installDir}/uninstaller/ClaudeDo.Installer.exe` (via the app's **Update now** or from Apps & Features), the self-update flow is identical. It is desirable for the uninstaller copy to be kept current — stale uninstaller binaries would otherwise drift behind and could have bugs the new app release expects fixed.
|
||||||
|
|
||||||
|
## Version Comparison (`VersionComparer`)
|
||||||
|
|
||||||
|
Centralizes the logic currently in `InstallModeDetector.IsNewer`:
|
||||||
|
|
||||||
|
- Parses both versions as `System.Version` after trimming a leading `v` / `V`.
|
||||||
|
- Returns `(bool isNewer, bool unparseable)`.
|
||||||
|
- Unparseable (e.g. `0.2.0-beta`) → treated as not newer; callers can surface a hint if desired.
|
||||||
|
|
||||||
|
Both `InstallModeDetector` (existing behavior) and `SelfUpdater` / `UpdateCheckService` (new callers) share this logic.
|
||||||
|
|
||||||
|
## Error Handling Summary
|
||||||
|
|
||||||
|
| Scenario | App behavior | Installer behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| Gitea unreachable | Silent; log to file; no banner | Silent; skip self-update; proceed to wizard |
|
||||||
|
| JSON parse error | Same as unreachable | Same as unreachable |
|
||||||
|
| Version unparseable | No banner; log a hint | No prompt; proceed |
|
||||||
|
| Installer exe not found on disk | `Update now` button disabled with tooltip | N/A |
|
||||||
|
| Download fails | N/A (app delegates to installer) | Error dialog with `[Continue with current installer]` |
|
||||||
|
| Checksum mismatch | N/A | Error dialog with `[Continue with current installer]` |
|
||||||
|
| Relaunch fails | N/A | Error dialog; user keeps temp exe and current exe both |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**New test project: `tests/ClaudeDo.Releases.Tests`**
|
||||||
|
|
||||||
|
- `ReleaseClientTests` — move existing installer tests covering `GetLatestReleaseAsync` and `DownloadAsync`.
|
||||||
|
- `VersionComparerTests` — boundary cases (equal, newer, older, unparseable, mixed `v`-prefix).
|
||||||
|
- `SelfUpdaterTests`:
|
||||||
|
- Asset-name regex correctly isolates version from `ClaudeDo.Installer-0.3.0.exe` and ignores `ClaudeDo-0.3.0-win-x64.zip`.
|
||||||
|
- Decision logic given mocked `IReleaseClient` responses.
|
||||||
|
- `--replace-self` handler: given a temp dummy file, the handler waits, deletes, copies — verified with a mock filesystem / temp dir.
|
||||||
|
|
||||||
|
**Existing project: `tests/ClaudeDo.Installer.Tests`**
|
||||||
|
|
||||||
|
- `SelfUpdateIntegrationTest`: build the installer, invoke it with `--replace-self <dummy>` pointing at a temp file, assert the dummy is replaced by a copy of the test installer, and the process exits cleanly. Run only on Windows CI.
|
||||||
|
|
||||||
|
**App tests (`tests/ClaudeDo.Ui.Tests` — add if absent):**
|
||||||
|
|
||||||
|
- `UpdateCheckServiceTests` — stubbed `IReleaseClient`, assert state transitions for each status.
|
||||||
|
- `InstallerLocatorTests` — fake filesystem, verify walk-up and registry-fallback discovery.
|
||||||
|
|
||||||
|
**Manual verification** (add to `docs/open.md`):
|
||||||
|
|
||||||
|
1. Build `v0.2.x` installer and upload to a test Gitea release.
|
||||||
|
2. Tag `v0.3.0` with new installer asset.
|
||||||
|
3. Install `v0.2.x`, run the `v0.2.x` installer again — confirm self-update prompt appears and replaces the binary in place.
|
||||||
|
4. With `v0.2.x` installed and a v0.3.0 release published, launch the app — confirm banner appears, **Update now** launches installer, update completes, app relaunches at v0.3.0.
|
||||||
|
5. Pull the network during check in both places — confirm silent fallback, no user-visible errors.
|
||||||
|
|
||||||
|
## Files Summary
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`
|
||||||
|
- `src/ClaudeDo.Releases/ReleaseClient.cs` (moved)
|
||||||
|
- `src/ClaudeDo.Releases/IReleaseClient.cs` (moved)
|
||||||
|
- `src/ClaudeDo.Releases/ChecksumVerifier.cs` (moved)
|
||||||
|
- `src/ClaudeDo.Releases/VersionComparer.cs` (new)
|
||||||
|
- `src/ClaudeDo.Releases/SelfUpdater.cs` (new)
|
||||||
|
- `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`
|
||||||
|
- `src/ClaudeDo.Ui/Services/InstallerLocator.cs`
|
||||||
|
- `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Installer/App.xaml.cs` — self-update run + `--replace-self` handling before wizard.
|
||||||
|
- `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` — use shared `VersionComparer`; drop now-moved types.
|
||||||
|
- `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — reference `ClaudeDo.Releases`.
|
||||||
|
- `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` — reference `ClaudeDo.Releases`.
|
||||||
|
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — banner + Help menu dropdown.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/MainViewModel.cs` — banner state, `CheckForUpdatesCommand`, wiring to `UpdateCheckService`.
|
||||||
|
- `src/ClaudeDo.App/Program.cs` (or existing DI composition root) — register `UpdateCheckService`, `InstallerLocator`, `IReleaseClient`, `HttpClient`.
|
||||||
|
- `ClaudeDo.slnx` — add new projects.
|
||||||
|
- `docs/open.md` — add manual verification checklist.
|
||||||
|
|
||||||
|
## Open Decisions Deferred to Implementation
|
||||||
|
|
||||||
|
- Exact Avalonia styling/layout of the banner is left to implementation to match the existing chrome polish pass from commit `3c420ac`.
|
||||||
|
- The Help dropdown control type (Avalonia `MenuItem` inside a `Menu`, or a custom flyout) is chosen during implementation based on what fits the current custom titlebar.
|
||||||
121
docs/superpowers/specs/2026-04-23-worker-log-footer-design.md
Normal file
121
docs/superpowers/specs/2026-04-23-worker-log-footer-design.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Worker Log Footer — Design
|
||||||
|
|
||||||
|
Date: 2026-04-23
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Surface important Worker lifecycle events (worktree created, Claude started, merged, etc.) in the UI footer as a single rotating, color-coded line. Gives the user ambient awareness of what the Worker just did without opening task details.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No log history, drawer, or scrollback
|
||||||
|
- No filtering or user-configurable verbosity
|
||||||
|
- No persistence across UI restarts
|
||||||
|
- No replay of events missed while UI was disconnected
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
Footer (`MainWindow.axaml`, row 2) layout changes from `StackPanel` to `DockPanel`:
|
||||||
|
|
||||||
|
- **Docked left:** existing connection pill (ellipse + `ONLINE/OFFLINE/RECONNECTING` text). The static `· WORKER` label is removed; the rotating log line replaces its purpose.
|
||||||
|
- **Docked right:** rotating worker-log line.
|
||||||
|
|
||||||
|
Line format: `14:32 · <message>`, rendered in the mono font at size 10 (matches existing footer typography). `TextTrimming="CharacterEllipsis"` so long task titles don't push out the connection pill.
|
||||||
|
|
||||||
|
The line is hidden when no event has been received within the last 30 seconds. Each new event replaces the current text and resets the 30-second timer. Timestamp is local time, `HH:mm`.
|
||||||
|
|
||||||
|
### Color mapping
|
||||||
|
|
||||||
|
Level is rendered via a `WorkerLogLevelToBrushConverter` (mirrors existing `StatusColorConverter` pattern):
|
||||||
|
|
||||||
|
| Level | Brush / color | Events |
|
||||||
|
|-----------|------------------------|-----------------------------------------------------|
|
||||||
|
| `Info` | `TextDimBrush` (dim) | Created worktree, Started Claude, Committed changes |
|
||||||
|
| `Success` | `#4CAF50` green | Merged, Finished (done) |
|
||||||
|
| `Warn` | `#FFA726` amber | Discarded worktree, Reset |
|
||||||
|
| `Error` | `#EF5350` red | Finished (failed) |
|
||||||
|
|
||||||
|
## Event Catalog
|
||||||
|
|
||||||
|
Seven emit sites. Each is added alongside the existing `_logger.LogInformation(...)` call — no log-sink plumbing, no central event bus.
|
||||||
|
|
||||||
|
| Site | Level | Message |
|
||||||
|
|-------------------------------------------|-----------|-----------------------------------------|
|
||||||
|
| `WorktreeManager.CreateAsync` | `Info` | `Created worktree for "<title>"` |
|
||||||
|
| `WorktreeManager.DiscardAsync` | `Warn` | `Discarded worktree for "<title>"` |
|
||||||
|
| `TaskMergeService.MergeAsync` | `Success` | `Merged "<title>" into <target>` |
|
||||||
|
| `TaskResetService.ResetAsync` | `Warn` | `Reset "<title>"` |
|
||||||
|
| `TaskRunner` — Claude launch | `Info` | `Started Claude for "<title>"` |
|
||||||
|
| `TaskRunner` — auto-commit | `Info` | `Committed changes in "<title>"` |
|
||||||
|
| `TaskRunner` — task finished | `Success` / `Error` | `Finished "<title>" (<status>)` |
|
||||||
|
|
||||||
|
`<title>` is the task's display title; `<target>` is the merge target branch; `<status>` is `done` or `failed`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Shared contract (`ClaudeDo.Data`)
|
||||||
|
|
||||||
|
New enum:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public enum WorkerLogLevel
|
||||||
|
{
|
||||||
|
Info,
|
||||||
|
Success,
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SignalR is configured to serialize enums as strings via `JsonStringEnumConverter` (added to the hub's JSON options in `Program.cs`). The UI client deserializes back to the same enum.
|
||||||
|
|
||||||
|
### Server side (`ClaudeDo.Worker`)
|
||||||
|
|
||||||
|
`HubBroadcaster` gets a new method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
|
||||||
|
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
|
||||||
|
```
|
||||||
|
|
||||||
|
`HubBroadcaster` is already injected into `TaskRunner`. For `WorktreeManager`, `TaskMergeService`, and `TaskResetService`, add constructor injection where it isn't already present. Each emit site calls `_broadcaster.WorkerLog(...)` with `DateTime.UtcNow` next to the existing `_logger.LogInformation(...)`.
|
||||||
|
|
||||||
|
### Client side (`ClaudeDo.Ui`)
|
||||||
|
|
||||||
|
**`WorkerClient`** — register a `HubConnection.On<string, WorkerLogLevel, DateTime>("WorkerLog", ...)` handler and expose a `WorkerLogReceived` event with a small `WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc)` record.
|
||||||
|
|
||||||
|
**Footer VM** — `StatusBarViewModel` already exists; extend it (or introduce a small `FooterViewModel` if `StatusBarViewModel` turns out to be scoped elsewhere — confirm during implementation). Add:
|
||||||
|
|
||||||
|
- `[ObservableProperty] string? currentEventText`
|
||||||
|
- `[ObservableProperty] WorkerLogLevel currentEventLevel`
|
||||||
|
- `[ObservableProperty] bool isEventVisible`
|
||||||
|
- A `DispatcherTimer` with a 30-second interval. On each `WorkerLogReceived`:
|
||||||
|
1. Format `HH:mm · <message>` from the event's local time.
|
||||||
|
2. Set `CurrentEventText`, `CurrentEventLevel`, `IsEventVisible = true`.
|
||||||
|
3. Stop and restart the timer.
|
||||||
|
- On timer tick: `IsEventVisible = false`, `CurrentEventText = null`.
|
||||||
|
|
||||||
|
**XAML** — `MainWindow.axaml` footer `StackPanel` becomes a `DockPanel`. Existing ellipses + connection text dock left in a horizontal `StackPanel`. A new `TextBlock` docks right, bound to `CurrentEventText` with `Foreground="{Binding CurrentEventLevel, Converter={StaticResource WorkerLogLevelToBrush}}"`, `IsVisible="{Binding IsEventVisible}"`, and `TextTrimming="CharacterEllipsis"`. Same mono font / size 10 as the rest of the footer.
|
||||||
|
|
||||||
|
**Converter** — `WorkerLogLevelToBrushConverter` in `Converters/` returns a brush per enum value, resolving theme brushes via `Application.Current.Resources` for `Info` (to honor theme swaps) and hard-coding the success/warn/error hex values (those are already hard-coded in the current footer).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit test `WorkerLogLevelToBrushConverter` with each enum value.
|
||||||
|
- Unit test the footer VM: receiving an event sets text/level/visibility and schedules a clear; a second event within 30s replaces and resets the timer; after 30s of silence the line hides.
|
||||||
|
- Manual smoke: run a task end-to-end and confirm each of the seven events surfaces with the expected color and copy.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
- **UI disconnected during an event:** event is lost. Acceptable — reconnect resumes receiving new events.
|
||||||
|
- **Burst of events:** each replaces the previous; only the most recent is shown.
|
||||||
|
- **Long task title:** ellipsized by the TextBlock; connection pill on the left stays fully visible.
|
||||||
|
- **Clock skew between Worker and UI:** timestamp is formatted in UI's local time from the wire-format `DateTime` (sent as UTC). Minor skew is cosmetic; no correctness impact.
|
||||||
|
|
||||||
|
## Out of Scope / Future
|
||||||
|
|
||||||
|
- Click-to-expand history drawer
|
||||||
|
- Per-list or per-task event filtering
|
||||||
|
- Persisting the most recent N events across restarts
|
||||||
@@ -17,7 +17,9 @@
|
|||||||
<converters:EqStatusConverter x:Key="EqStatus"/>
|
<converters:EqStatusConverter x:Key="EqStatus"/>
|
||||||
<converters:UpperCaseConverter x:Key="UpperCase"/>
|
<converters:UpperCaseConverter x:Key="UpperCase"/>
|
||||||
<converters:IconKeyConverter x:Key="IconKey"/>
|
<converters:IconKeyConverter x:Key="IconKey"/>
|
||||||
<converters:DotBrushConverter x:Key="DotBrush"/>
|
<converters:DotBrushConverter x:Key="DotBrush"/>
|
||||||
|
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
|
||||||
|
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
using ClaudeDo.Ui;
|
using ClaudeDo.Ui;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
@@ -9,6 +10,8 @@ using ClaudeDo.Ui.ViewModels.Modals;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Reflection;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace ClaudeDo.App;
|
namespace ClaudeDo.App;
|
||||||
@@ -75,6 +78,20 @@ sealed class Program
|
|||||||
sc.AddSingleton<GitService>();
|
sc.AddSingleton<GitService>();
|
||||||
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
||||||
|
|
||||||
|
// Release check + installer update
|
||||||
|
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
|
||||||
|
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
|
||||||
|
sc.AddSingleton<InstallerLocator>();
|
||||||
|
sc.AddSingleton(sp =>
|
||||||
|
{
|
||||||
|
var releases = sp.GetRequiredService<IReleaseClient>();
|
||||||
|
var informational = Assembly.GetEntryAssembly()?
|
||||||
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||||
|
// Strip MinVer build metadata ("+sha") and any prerelease suffix for the update-compare.
|
||||||
|
var version = (informational ?? "0.0.0").Split('+')[0];
|
||||||
|
return new UpdateCheckService(releases, version);
|
||||||
|
});
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
sc.AddTransient<WorktreeModalViewModel>();
|
sc.AddTransient<WorktreeModalViewModel>();
|
||||||
sc.AddTransient<SettingsModalViewModel>();
|
sc.AddTransient<SettingsModalViewModel>();
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ public class ClaudeDoDbContext : DbContext
|
|||||||
walCmd.ExecuteNonQuery();
|
walCmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable FK enforcement — SQLite defaults to OFF per connection.
|
||||||
|
using (var fkCmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
fkCmd.CommandText = "PRAGMA foreign_keys=ON;";
|
||||||
|
fkCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
// If the 'lists' table exists but __EFMigrationsHistory does not,
|
// If the 'lists' table exists but __EFMigrationsHistory does not,
|
||||||
// this is a pre-EF database. Baseline the InitialCreate migration.
|
// this is a pre-EF database. Baseline the InitialCreate migration.
|
||||||
using (var cmd = conn.CreateCommand())
|
using (var cmd = conn.CreateCommand())
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
: v == TaskStatus.Running ? "running"
|
: v == TaskStatus.Running ? "running"
|
||||||
: v == TaskStatus.Done ? "done"
|
: v == TaskStatus.Done ? "done"
|
||||||
: v == TaskStatus.Failed ? "failed"
|
: v == TaskStatus.Failed ? "failed"
|
||||||
|
: v == TaskStatus.Planning ? "planning"
|
||||||
|
: v == TaskStatus.Planned ? "planned"
|
||||||
|
: v == TaskStatus.Draft ? "draft"
|
||||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||||
|
|
||||||
private static TaskStatus StatusFromString(string v)
|
private static TaskStatus StatusFromString(string v)
|
||||||
@@ -22,6 +25,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
: v == "running" ? TaskStatus.Running
|
: v == "running" ? TaskStatus.Running
|
||||||
: v == "done" ? TaskStatus.Done
|
: v == "done" ? TaskStatus.Done
|
||||||
: v == "failed" ? TaskStatus.Failed
|
: v == "failed" ? TaskStatus.Failed
|
||||||
|
: v == "planning" ? TaskStatus.Planning
|
||||||
|
: v == "planned" ? TaskStatus.Planned
|
||||||
|
: v == "draft" ? TaskStatus.Draft
|
||||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||||
|
|
||||||
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
|
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
|
||||||
@@ -53,6 +59,16 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
builder.Property(t => t.Notes).HasColumnName("notes");
|
builder.Property(t => t.Notes).HasColumnName("notes");
|
||||||
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||||
|
|
||||||
|
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
|
||||||
|
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
|
||||||
|
builder.Property(t => t.PlanningSessionToken).HasColumnName("planning_session_token");
|
||||||
|
builder.Property(t => t.PlanningFinalizedAt).HasColumnName("planning_finalized_at");
|
||||||
|
|
||||||
|
builder.HasOne(t => t.Parent)
|
||||||
|
.WithMany(t => t.Children)
|
||||||
|
.HasForeignKey(t => t.ParentTaskId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
builder.HasOne(t => t.List)
|
builder.HasOne(t => t.List)
|
||||||
.WithMany(l => l.Tasks)
|
.WithMany(l => l.Tasks)
|
||||||
.HasForeignKey(t => t.ListId)
|
.HasForeignKey(t => t.ListId)
|
||||||
@@ -76,5 +92,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
||||||
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
||||||
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
|
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
|
||||||
|
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPlanningSupport : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "parent_task_id",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "planning_finalized_at",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "planning_session_id",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "planning_session_token",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_tasks_parent_task_id",
|
||||||
|
table: "tasks",
|
||||||
|
column: "parent_task_id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_tasks_tasks_parent_task_id",
|
||||||
|
table: "tasks",
|
||||||
|
column: "parent_task_id",
|
||||||
|
principalTable: "tasks",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_tasks_tasks_parent_task_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "idx_tasks_parent_task_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "parent_task_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "planning_finalized_at",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "planning_session_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "planning_session_token",
|
||||||
|
table: "tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -273,6 +273,22 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("notes");
|
.HasColumnName("notes");
|
||||||
|
|
||||||
|
b.Property<string>("ParentTaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("parent_task_id");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_finalized_at");
|
||||||
|
|
||||||
|
b.Property<string>("PlanningSessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_session_id");
|
||||||
|
|
||||||
|
b.Property<string>("PlanningSessionToken")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_session_token");
|
||||||
|
|
||||||
b.Property<string>("Result")
|
b.Property<string>("Result")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("result");
|
.HasColumnName("result");
|
||||||
@@ -310,6 +326,9 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
b.HasIndex("ListId")
|
b.HasIndex("ListId")
|
||||||
.HasDatabaseName("idx_tasks_list_id");
|
.HasDatabaseName("idx_tasks_list_id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId")
|
||||||
|
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||||
|
|
||||||
b.HasIndex("Status")
|
b.HasIndex("Status")
|
||||||
.HasDatabaseName("idx_tasks_status");
|
.HasDatabaseName("idx_tasks_status");
|
||||||
|
|
||||||
@@ -502,7 +521,14 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||||
|
.WithMany("Children")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.Navigation("List");
|
b.Navigation("List");
|
||||||
|
|
||||||
|
b.Navigation("Parent");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||||
@@ -566,6 +592,8 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("Children");
|
||||||
|
|
||||||
b.Navigation("Runs");
|
b.Navigation("Runs");
|
||||||
|
|
||||||
b.Navigation("Subtasks");
|
b.Navigation("Subtasks");
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ public enum TaskStatus
|
|||||||
Running,
|
Running,
|
||||||
Done,
|
Done,
|
||||||
Failed,
|
Failed,
|
||||||
|
Planning,
|
||||||
|
Planned,
|
||||||
|
Draft,
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TaskEntity
|
public sealed class TaskEntity
|
||||||
@@ -31,10 +34,18 @@ public sealed class TaskEntity
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
public string? ParentTaskId { get; set; }
|
||||||
|
public string? PlanningSessionId { get; set; }
|
||||||
|
public string? PlanningSessionToken { get; set; }
|
||||||
|
public DateTime? PlanningFinalizedAt { get; set; }
|
||||||
|
|
||||||
// Navigation properties
|
// Navigation properties
|
||||||
public ListEntity List { get; set; } = null!;
|
public ListEntity List { get; set; } = null!;
|
||||||
public WorktreeEntity? Worktree { get; set; }
|
public WorktreeEntity? Worktree { get; set; }
|
||||||
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
|
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
|
||||||
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
|
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
|
||||||
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
|
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
|
||||||
|
|
||||||
|
public TaskEntity? Parent { get; set; }
|
||||||
|
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/ClaudeDo.Data/Models/WorkerLogLevel.cs
Normal file
9
src/ClaudeDo.Data/Models/WorkerLogLevel.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public enum WorkerLogLevel
|
||||||
|
{
|
||||||
|
Info,
|
||||||
|
Success,
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
@@ -206,6 +206,206 @@ public sealed class TaskRepository
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Planning
|
||||||
|
|
||||||
|
public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskEntity> CreateChildAsync(
|
||||||
|
string parentId,
|
||||||
|
string title,
|
||||||
|
string? description,
|
||||||
|
IReadOnlyList<string>? tagNames,
|
||||||
|
string? commitType,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null)
|
||||||
|
throw new InvalidOperationException($"Parent task {parentId} not found.");
|
||||||
|
|
||||||
|
var maxSort = await _context.Tasks
|
||||||
|
.Where(t => t.ListId == parent.ListId)
|
||||||
|
.Select(t => (int?)t.SortOrder)
|
||||||
|
.MaxAsync(ct);
|
||||||
|
|
||||||
|
var child = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = parent.ListId,
|
||||||
|
Title = title,
|
||||||
|
Description = description,
|
||||||
|
Status = TaskStatus.Draft,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType,
|
||||||
|
ParentTaskId = parentId,
|
||||||
|
SortOrder = (maxSort ?? -1) + 1,
|
||||||
|
};
|
||||||
|
_context.Tasks.Add(child);
|
||||||
|
|
||||||
|
if (tagNames is not null && tagNames.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var tagName in tagNames.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == tagName, ct);
|
||||||
|
if (tag is null)
|
||||||
|
{
|
||||||
|
tag = new TagEntity { Name = tagName };
|
||||||
|
_context.Tags.Add(tag);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
child.Tags.Add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskEntity?> SetPlanningStartedAsync(
|
||||||
|
string taskId,
|
||||||
|
string sessionToken,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var affected = await _context.Tasks
|
||||||
|
.Where(t => t.Id == taskId && t.Status == TaskStatus.Manual)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Planning)
|
||||||
|
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
|
||||||
|
|
||||||
|
if (affected == 0) return null;
|
||||||
|
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdatePlanningSessionIdAsync(
|
||||||
|
string parentId,
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.PlanningSessionId, sessionId), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskEntity?> FindByPlanningTokenAsync(
|
||||||
|
string token,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
return await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> FinalizePlanningAsync(
|
||||||
|
string parentId,
|
||||||
|
bool queueAgentTasks,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var parent = await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(t => t.List).ThenInclude(l => l.Tags)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null || parent.Status != TaskStatus.Planning)
|
||||||
|
throw new InvalidOperationException($"Task {parentId} is not in Planning state.");
|
||||||
|
|
||||||
|
var listHasAgentTag = parent.List.Tags.Any(t => t.Name == "agent");
|
||||||
|
|
||||||
|
var drafts = await _context.Tasks
|
||||||
|
.Include(t => t.Tags)
|
||||||
|
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
foreach (var draft in drafts)
|
||||||
|
{
|
||||||
|
var childHasAgentTag = draft.Tags.Any(t => t.Name == "agent");
|
||||||
|
var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag);
|
||||||
|
draft.Status = shouldQueue ? TaskStatus.Queued : TaskStatus.Manual;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalizedAt = DateTime.UtcNow;
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Planned)
|
||||||
|
.SetProperty(t => t.PlanningFinalizedAt, finalizedAt)
|
||||||
|
.SetProperty(t => t.PlanningSessionToken, (string?)null), ct);
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DiscardPlanningAsync(
|
||||||
|
string parentId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var parent = await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null || parent.Status != TaskStatus.Planning)
|
||||||
|
{
|
||||||
|
await tx.RollbackAsync(ct);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Manual)
|
||||||
|
.SetProperty(t => t.PlanningSessionId, (string?)null)
|
||||||
|
.SetProperty(t => t.PlanningSessionToken, (string?)null)
|
||||||
|
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
|
||||||
|
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TryCompleteParentAsync(
|
||||||
|
string parentId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null || parent.Status != TaskStatus.Planned) return;
|
||||||
|
|
||||||
|
var children = await _context.Tasks
|
||||||
|
.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.Select(t => t.Status)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (children.Count == 0) return;
|
||||||
|
|
||||||
|
bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed);
|
||||||
|
if (!allTerminal) return;
|
||||||
|
|
||||||
|
bool anyFailed = children.Any(s => s == TaskStatus.Failed);
|
||||||
|
var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done;
|
||||||
|
var finishedAt = DateTime.UtcNow;
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, finalStatus)
|
||||||
|
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region Queue selection
|
#region Queue selection
|
||||||
|
|
||||||
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Net.Http;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
using ClaudeDo.Installer.Pages.InstallPage;
|
using ClaudeDo.Installer.Pages.InstallPage;
|
||||||
using ClaudeDo.Installer.Pages.PathsPage;
|
using ClaudeDo.Installer.Pages.PathsPage;
|
||||||
using ClaudeDo.Installer.Pages.ServicePage;
|
using ClaudeDo.Installer.Pages.ServicePage;
|
||||||
@@ -21,6 +22,104 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
|
|
||||||
|
// --- Self-update pre-flight ---
|
||||||
|
// Resolve current exe path. Assembly.Location may point to a .dll for apphost-based
|
||||||
|
// .NET apps; swap to the .exe companion when that happens.
|
||||||
|
var currentExePath = Assembly.GetEntryAssembly()!.Location;
|
||||||
|
if (currentExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
currentExePath = System.IO.Path.ChangeExtension(currentExePath, ".exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arg form: --replace-self "<old-path>"
|
||||||
|
var replaceSelfIndex = Array.FindIndex(e.Args, a => a.Equals("--replace-self", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (replaceSelfIndex >= 0 && replaceSelfIndex + 1 < e.Args.Length)
|
||||||
|
{
|
||||||
|
var oldPath = e.Args[replaceSelfIndex + 1];
|
||||||
|
var relaunched = await SelfUpdater.HandleReplaceSelfAsync(
|
||||||
|
oldPath: oldPath,
|
||||||
|
currentExePath: currentExePath,
|
||||||
|
launchProcess: path =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
});
|
||||||
|
if (relaunched)
|
||||||
|
{
|
||||||
|
Shutdown(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Replacement failed — fall through to normal wizard from the temp location.
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Normal launch: check for a newer installer.
|
||||||
|
using var selfUpdateHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
var selfUpdateReleases = new ReleaseClient(selfUpdateHttp);
|
||||||
|
var currentVersion = GetInstallerVersion();
|
||||||
|
|
||||||
|
var decision = await SelfUpdater.DecideUpdateAsync(selfUpdateReleases, currentVersion, CancellationToken.None);
|
||||||
|
if (decision.Kind == SelfUpdateDecisionKind.UpdateAvailable)
|
||||||
|
{
|
||||||
|
var prompt = new SelfUpdatePromptWindow(currentVersion, decision.LatestVersion!);
|
||||||
|
DarkTitleBar.Apply(prompt);
|
||||||
|
var ok = prompt.ShowDialog() == true;
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
Shutdown(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (prompt.Choice == SelfUpdateChoice.Update)
|
||||||
|
{
|
||||||
|
prompt.ShowProgress("Downloading...");
|
||||||
|
var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ClaudeDo.Installer.Update");
|
||||||
|
var verifiedPath = await SelfUpdater.DownloadAndVerifyAsync(
|
||||||
|
selfUpdateReleases,
|
||||||
|
decision.InstallerAsset!,
|
||||||
|
decision.ChecksumsAsset!,
|
||||||
|
tempDir,
|
||||||
|
new Progress<long>(_ => { }),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
if (verifiedPath is null)
|
||||||
|
{
|
||||||
|
MessageBox.Show(prompt,
|
||||||
|
"Update download or verification failed. Continuing with current installer.",
|
||||||
|
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo(verifiedPath)
|
||||||
|
{
|
||||||
|
UseShellExecute = true,
|
||||||
|
};
|
||||||
|
psi.ArgumentList.Add("--replace-self");
|
||||||
|
psi.ArgumentList.Add(currentExePath);
|
||||||
|
System.Diagnostics.Process.Start(psi);
|
||||||
|
Shutdown(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(prompt,
|
||||||
|
"Failed to launch updated installer: " + ex.Message + "\nContinuing with current installer.",
|
||||||
|
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SelfUpdateChoice.Continue — fall through to normal wizard.
|
||||||
|
}
|
||||||
|
// No-update or check failed — fall through to normal wizard.
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Existing wizard start-up unchanged below this line ---
|
||||||
|
|
||||||
_services = BuildServices();
|
_services = BuildServices();
|
||||||
|
|
||||||
var context = _services.GetRequiredService<InstallContext>();
|
var context = _services.GetRequiredService<InstallContext>();
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
public sealed record DetectedState(
|
public sealed record DetectedState(
|
||||||
@@ -31,7 +33,9 @@ public sealed class InstallModeDetector
|
|||||||
return new DetectedState(InstallerMode.Config, manifest, null, null);
|
return new DetectedState(InstallerMode.Config, manifest, null, null);
|
||||||
|
|
||||||
var latestVersion = release.TagName.TrimStart('v', 'V');
|
var latestVersion = release.TagName.TrimStart('v', 'V');
|
||||||
var newer = IsNewer(latestVersion, manifest.Version, out var unparseable);
|
var cmp = VersionComparer.Compare(latestVersion, manifest.Version);
|
||||||
|
var newer = cmp.IsNewer;
|
||||||
|
var unparseable = cmp.Unparseable;
|
||||||
if (newer)
|
if (newer)
|
||||||
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
|
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
|
||||||
|
|
||||||
@@ -41,16 +45,4 @@ public sealed class InstallModeDetector
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
|
|
||||||
/// AND latest > current. Semver pre-release tags like "0.2.0-beta" fail to parse and are
|
|
||||||
/// treated as "not newer" — the user drops into Config mode with no update offered, but
|
|
||||||
/// <paramref name="unparseable"/> is set so the UI can surface a hint.
|
|
||||||
/// </summary>
|
|
||||||
private static bool IsNewer(string latest, string current, out bool unparseable)
|
|
||||||
{
|
|
||||||
unparseable = !Version.TryParse(latest, out var lv) | !Version.TryParse(current, out var cv);
|
|
||||||
if (unparseable) return false;
|
|
||||||
return lv > cv;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Steps;
|
namespace ClaudeDo.Installer.Steps;
|
||||||
|
|
||||||
|
|||||||
25
src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml
Normal file
25
src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<Window x:Class="ClaudeDo.Installer.Views.SelfUpdatePromptWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="ClaudeDo Installer Update"
|
||||||
|
Width="460" Height="200"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
Background="#1a1a1a" Foreground="#f0f0f0">
|
||||||
|
<Grid Margin="20">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Grid.Row="0" FontSize="16" FontWeight="SemiBold" Text="A newer installer is available"/>
|
||||||
|
<TextBlock Grid.Row="1" Margin="0,8,0,0" TextWrapping="Wrap" x:Name="DetailText"/>
|
||||||
|
<TextBlock Grid.Row="2" Margin="0,12,0,0" TextWrapping="Wrap" Foreground="#a0a0a0" x:Name="ProgressText" Visibility="Collapsed"/>
|
||||||
|
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button x:Name="UpdateBtn" Content="Update" MinWidth="90" Margin="4,0" Padding="10,4" Click="UpdateBtn_Click" IsDefault="True"/>
|
||||||
|
<Button x:Name="ContinueBtn" Content="Continue anyway" MinWidth="140" Margin="4,0" Padding="10,4" Click="ContinueBtn_Click"/>
|
||||||
|
<Button x:Name="CancelBtn" Content="Cancel" MinWidth="90" Margin="4,0" Padding="10,4" Click="CancelBtn_Click" IsCancel="True"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
42
src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs
Normal file
42
src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Views;
|
||||||
|
|
||||||
|
public enum SelfUpdateChoice { Update, Continue, Cancel }
|
||||||
|
|
||||||
|
public partial class SelfUpdatePromptWindow : Window
|
||||||
|
{
|
||||||
|
public SelfUpdateChoice Choice { get; private set; } = SelfUpdateChoice.Cancel;
|
||||||
|
|
||||||
|
public SelfUpdatePromptWindow(string currentVersion, string latestVersion)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DetailText.Text = $"Installer v{latestVersion} is available (you are running v{currentVersion}). Update before continuing?";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowProgress(string text)
|
||||||
|
{
|
||||||
|
ProgressText.Text = text;
|
||||||
|
ProgressText.Visibility = Visibility.Visible;
|
||||||
|
UpdateBtn.IsEnabled = false;
|
||||||
|
ContinueBtn.IsEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateBtn_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Choice = SelfUpdateChoice.Update;
|
||||||
|
DialogResult = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ContinueBtn_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Choice = SelfUpdateChoice.Continue;
|
||||||
|
DialogResult = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelBtn_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Choice = SelfUpdateChoice.Cancel;
|
||||||
|
DialogResult = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
using ClaudeDo.Installer.Steps;
|
using ClaudeDo.Installer.Steps;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
public static class ChecksumVerifier
|
public static class ChecksumVerifier
|
||||||
{
|
{
|
||||||
8
src/ClaudeDo.Releases/ClaudeDo.Releases.csproj
Normal file
8
src/ClaudeDo.Releases/ClaudeDo.Releases.csproj
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
|
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ using System.IO;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
public sealed class ReleaseClient : IReleaseClient
|
public sealed class ReleaseClient : IReleaseClient
|
||||||
{
|
{
|
||||||
15
src/ClaudeDo.Releases/SelfUpdateResult.cs
Normal file
15
src/ClaudeDo.Releases/SelfUpdateResult.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
|
public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version);
|
||||||
|
|
||||||
|
public enum SelfUpdateDecisionKind
|
||||||
|
{
|
||||||
|
NoUpdate,
|
||||||
|
UpdateAvailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SelfUpdateDecision(
|
||||||
|
SelfUpdateDecisionKind Kind,
|
||||||
|
string? LatestVersion = null,
|
||||||
|
ReleaseAsset? InstallerAsset = null,
|
||||||
|
ReleaseAsset? ChecksumsAsset = null);
|
||||||
126
src/ClaudeDo.Releases/SelfUpdater.cs
Normal file
126
src/ClaudeDo.Releases/SelfUpdater.cs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
|
public static partial class SelfUpdater
|
||||||
|
{
|
||||||
|
[GeneratedRegex(@"^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex InstallerAssetRegex();
|
||||||
|
|
||||||
|
public static InstallerAssetMatch? FindInstallerAsset(IEnumerable<ReleaseAsset> assets)
|
||||||
|
{
|
||||||
|
foreach (var asset in assets)
|
||||||
|
{
|
||||||
|
var m = InstallerAssetRegex().Match(asset.Name);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
return new InstallerAssetMatch(asset, m.Groups["version"].Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<SelfUpdateDecision> DecideUpdateAsync(
|
||||||
|
IReleaseClient releases,
|
||||||
|
string currentVersion,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
GiteaRelease? release;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
release = await releases.GetLatestReleaseAsync(ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (release is null)
|
||||||
|
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
||||||
|
|
||||||
|
var match = FindInstallerAsset(release.Assets);
|
||||||
|
if (match is null)
|
||||||
|
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
||||||
|
|
||||||
|
var cmp = VersionComparer.Compare(match.Version, currentVersion);
|
||||||
|
if (!cmp.IsNewer)
|
||||||
|
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
||||||
|
|
||||||
|
var checksums = release.Assets.FirstOrDefault(
|
||||||
|
a => string.Equals(a.Name, "checksums.txt", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
return new SelfUpdateDecision(
|
||||||
|
SelfUpdateDecisionKind.UpdateAvailable,
|
||||||
|
LatestVersion: match.Version,
|
||||||
|
InstallerAsset: match.Asset,
|
||||||
|
ChecksumsAsset: checksums);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<bool> HandleReplaceSelfAsync(
|
||||||
|
string oldPath,
|
||||||
|
string currentExePath,
|
||||||
|
Func<string, bool> launchProcess,
|
||||||
|
int maxWaitMs = 5000)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow.AddMilliseconds(maxWaitMs);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(oldPath))
|
||||||
|
{
|
||||||
|
File.Delete(oldPath);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
await Task.Delay(100);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
await Task.Delay(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(oldPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Copy(currentExePath, oldPath, overwrite: false);
|
||||||
|
return launchProcess(oldPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string?> DownloadAndVerifyAsync(
|
||||||
|
IReleaseClient releases,
|
||||||
|
ReleaseAsset installerAsset,
|
||||||
|
ReleaseAsset checksumsAsset,
|
||||||
|
string tempDir,
|
||||||
|
IProgress<long> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
var installerPath = Path.Combine(tempDir, installerAsset.Name);
|
||||||
|
var checksumsPath = Path.Combine(tempDir, "checksums.txt");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await releases.DownloadAsync(installerAsset.BrowserDownloadUrl, installerPath, progress, ct);
|
||||||
|
await releases.DownloadAsync(checksumsAsset.BrowserDownloadUrl, checksumsPath, new Progress<long>(_ => { }), ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var checksumsText = await File.ReadAllTextAsync(checksumsPath, ct);
|
||||||
|
var map = ChecksumVerifier.ParseChecksumsFile(checksumsText);
|
||||||
|
if (!map.TryGetValue(installerAsset.Name, out var expected))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return ChecksumVerifier.Verify(installerPath, expected) ? installerPath : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/ClaudeDo.Releases/VersionComparer.cs
Normal file
18
src/ClaudeDo.Releases/VersionComparer.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
|
public readonly record struct VersionCompareResult(bool IsNewer, bool Unparseable);
|
||||||
|
|
||||||
|
public static class VersionComparer
|
||||||
|
{
|
||||||
|
public static VersionCompareResult Compare(string latest, string current)
|
||||||
|
{
|
||||||
|
var latestTrimmed = (latest ?? "").TrimStart('v', 'V');
|
||||||
|
var currentTrimmed = (current ?? "").TrimStart('v', 'V');
|
||||||
|
|
||||||
|
var unparseable = !Version.TryParse(latestTrimmed, out var lv)
|
||||||
|
| !Version.TryParse(currentTrimmed, out var cv);
|
||||||
|
|
||||||
|
if (unparseable) return new VersionCompareResult(false, true);
|
||||||
|
return new VersionCompareResult(lv > cv, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -9,6 +10,12 @@
|
|||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ClaudeDo.Ui.Tests" />
|
||||||
|
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
13
src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs
Normal file
13
src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class BoolToDraftOpacityConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is true ? 0.7 : 1.0;
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
14
src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs
Normal file
14
src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class BoolToItalicConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is true ? FontStyle.Italic : FontStyle.Normal;
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
45
src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs
Normal file
45
src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class WorkerLogLevelToBrushConverter : IValueConverter
|
||||||
|
{
|
||||||
|
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#4CAF50"));
|
||||||
|
private static readonly IBrush WarnBrush = new SolidColorBrush(Color.Parse("#FFA726"));
|
||||||
|
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#EF5350"));
|
||||||
|
private static readonly IBrush InfoFallback = new SolidColorBrush(Color.Parse("#888888"));
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is not WorkerLogLevel level)
|
||||||
|
return AvaloniaProperty.UnsetValue;
|
||||||
|
|
||||||
|
return level switch
|
||||||
|
{
|
||||||
|
WorkerLogLevel.Success => SuccessBrush,
|
||||||
|
WorkerLogLevel.Warn => WarnBrush,
|
||||||
|
WorkerLogLevel.Error => ErrorBrush,
|
||||||
|
WorkerLogLevel.Info => ResolveInfoBrush(),
|
||||||
|
_ => AvaloniaProperty.UnsetValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
private static IBrush ResolveInfoBrush()
|
||||||
|
{
|
||||||
|
if (Application.Current is { } app &&
|
||||||
|
app.Resources.TryGetResource("TextDimBrush", app.ActualThemeVariant, out var res) &&
|
||||||
|
res is IBrush brush)
|
||||||
|
{
|
||||||
|
return brush;
|
||||||
|
}
|
||||||
|
return InfoFallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,14 @@
|
|||||||
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
|
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
|
||||||
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
|
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Settings (gear) -->
|
||||||
|
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Badge brushes -->
|
||||||
|
<SolidColorBrush x:Key="DraftBadgeBrush" Color="#4A5568"/>
|
||||||
|
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="#D69E2E"/>
|
||||||
|
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="#3182CE"/>
|
||||||
|
|
||||||
</Styles.Resources>
|
</Styles.Resources>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
@@ -863,4 +871,31 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- PLANNING / DRAFT BADGES -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.badge">
|
||||||
|
<Setter Property="CornerRadius" Value="3"/>
|
||||||
|
<Setter Property="Padding" Value="4,1"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge > TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="9"/>
|
||||||
|
<Setter Property="FontWeight" Value="Bold"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.draft">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.planning">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.badge.planned">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
</Styles>
|
</Styles>
|
||||||
|
|||||||
11
src/ClaudeDo.Ui/Services/IWorkerClient.cs
Normal file
11
src/ClaudeDo.Ui/Services/IWorkerClient.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
public interface IWorkerClient
|
||||||
|
{
|
||||||
|
Task WakeQueueAsync();
|
||||||
|
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
|
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
|
Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
|
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
|
||||||
|
Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
47
src/ClaudeDo.Ui/Services/InstallerLocator.cs
Normal file
47
src/ClaudeDo.Ui/Services/InstallerLocator.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
public sealed class InstallerLocator
|
||||||
|
{
|
||||||
|
private const string InstallJson = "install.json";
|
||||||
|
private const string InstallerExe = "ClaudeDo.Installer.exe";
|
||||||
|
private const string UninstallerSubdir = "uninstaller";
|
||||||
|
|
||||||
|
public string? Find()
|
||||||
|
=> FindByWalkingUp(AppContext.BaseDirectory) ?? FindByRegistry();
|
||||||
|
|
||||||
|
public string? FindByWalkingUp(string startDir)
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(startDir);
|
||||||
|
while (dir is not null)
|
||||||
|
{
|
||||||
|
var manifest = Path.Combine(dir.FullName, InstallJson);
|
||||||
|
if (File.Exists(manifest))
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir.FullName, UninstallerSubdir, InstallerExe);
|
||||||
|
return File.Exists(candidate) ? candidate : null;
|
||||||
|
}
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||||
|
public string? FindByRegistry()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Microsoft.Win32.Registry.LocalMachine
|
||||||
|
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
|
||||||
|
var location = key?.GetValue("InstallLocation") as string;
|
||||||
|
if (string.IsNullOrEmpty(location)) return null;
|
||||||
|
var candidate = Path.Combine(location, UninstallerSubdir, InstallerExe);
|
||||||
|
return File.Exists(candidate) ? candidate : null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/ClaudeDo.Ui/Services/PlanningDtos.cs
Normal file
18
src/ClaudeDo.Ui/Services/PlanningDtos.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
public sealed record PlanningSessionFilesDto(
|
||||||
|
string SessionDirectory,
|
||||||
|
string McpConfigPath,
|
||||||
|
string SystemPromptPath,
|
||||||
|
string InitialPromptPath);
|
||||||
|
|
||||||
|
public sealed record PlanningSessionStartInfo(
|
||||||
|
string ParentTaskId,
|
||||||
|
string WorkingDir,
|
||||||
|
PlanningSessionFilesDto Files);
|
||||||
|
|
||||||
|
public sealed record PlanningSessionResumeInfo(
|
||||||
|
string ParentTaskId,
|
||||||
|
string WorkingDir,
|
||||||
|
string ClaudeSessionId,
|
||||||
|
string McpConfigPath);
|
||||||
73
src/ClaudeDo.Ui/Services/UpdateCheckService.cs
Normal file
73
src/ClaudeDo.Ui/Services/UpdateCheckService.cs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
using ClaudeDo.Releases;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
public enum UpdateCheckStatus
|
||||||
|
{
|
||||||
|
NeverChecked,
|
||||||
|
CheckFailed,
|
||||||
|
UpToDate,
|
||||||
|
UpdateAvailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class UpdateCheckService : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IReleaseClient _releases;
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isUpdateAvailable;
|
||||||
|
[ObservableProperty] private string? _latestVersion;
|
||||||
|
[ObservableProperty] private string _currentVersion;
|
||||||
|
[ObservableProperty] private bool _isChecking;
|
||||||
|
[ObservableProperty] private UpdateCheckStatus _lastCheckStatus = UpdateCheckStatus.NeverChecked;
|
||||||
|
|
||||||
|
public UpdateCheckService(IReleaseClient releases, string currentVersion)
|
||||||
|
{
|
||||||
|
_releases = releases;
|
||||||
|
_currentVersion = currentVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CheckNowAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
IsChecking = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
GiteaRelease? rel;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rel = await _releases.GetLatestReleaseAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
LastCheckStatus = UpdateCheckStatus.CheckFailed;
|
||||||
|
IsUpdateAvailable = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rel is null)
|
||||||
|
{
|
||||||
|
LastCheckStatus = UpdateCheckStatus.CheckFailed;
|
||||||
|
IsUpdateAvailable = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var latest = (rel.TagName ?? "").TrimStart('v', 'V');
|
||||||
|
var cmp = VersionComparer.Compare(latest, CurrentVersion);
|
||||||
|
if (cmp.IsNewer)
|
||||||
|
{
|
||||||
|
LatestVersion = latest;
|
||||||
|
IsUpdateAvailable = true;
|
||||||
|
LastCheckStatus = UpdateCheckStatus.UpdateAvailable;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
IsUpdateAvailable = false;
|
||||||
|
LastCheckStatus = UpdateCheckStatus.UpToDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsChecking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.SignalR.Client;
|
|||||||
namespace ClaudeDo.Ui.Services;
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
public record ActiveTask(string Slot, string TaskId, DateTime StartedAt);
|
public record ActiveTask(string Slot, string TaskId, DateTime StartedAt);
|
||||||
|
public sealed record WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc);
|
||||||
|
|
||||||
sealed class IndefiniteRetryPolicy : IRetryPolicy
|
sealed class IndefiniteRetryPolicy : IRetryPolicy
|
||||||
{
|
{
|
||||||
@@ -24,7 +25,7 @@ sealed class IndefiniteRetryPolicy : IRetryPolicy
|
|||||||
_delays[Math.Min(retryContext.PreviousRetryCount, _delays.Length - 1)];
|
_delays[Math.Min(retryContext.PreviousRetryCount, _delays.Length - 1)];
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerClient
|
||||||
{
|
{
|
||||||
private readonly HubConnection _hub;
|
private readonly HubConnection _hub;
|
||||||
private CancellationTokenSource? _startCts;
|
private CancellationTokenSource? _startCts;
|
||||||
@@ -46,6 +47,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string>? RunNowRequestedEvent;
|
public event Action<string>? RunNowRequestedEvent;
|
||||||
public event Action<string>? ListUpdatedEvent;
|
public event Action<string>? ListUpdatedEvent;
|
||||||
|
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||||
|
|
||||||
public WorkerClient(string signalRUrl)
|
public WorkerClient(string signalRUrl)
|
||||||
{
|
{
|
||||||
@@ -116,6 +118,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() => ListUpdatedEvent?.Invoke(listId));
|
Dispatcher.UIThread.Post(() => ListUpdatedEvent?.Invoke(listId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_hub.On<string, WorkerLogLevel, DateTime>("WorkerLog", (message, level, timestampUtc) =>
|
||||||
|
{
|
||||||
|
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StartAsync()
|
public Task StartAsync()
|
||||||
@@ -301,7 +308,14 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
|
|
||||||
public async Task<ListConfigDto?> GetListConfigAsync(string listId)
|
public async Task<ListConfigDto?> GetListConfigAsync(string listId)
|
||||||
{
|
{
|
||||||
return await _hub.InvokeAsync<ListConfigDto?>("GetListConfig", listId);
|
try
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<ListConfigDto?>("GetListConfig", listId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
|
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
|
||||||
@@ -333,6 +347,33 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
|
||||||
|
|
||||||
|
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
|
||||||
|
|
||||||
|
// IWorkerClient explicit implementations (drop typed return values)
|
||||||
|
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||||
|
=> await StartPlanningSessionAsync(taskId, ct);
|
||||||
|
async Task IWorkerClient.ResumePlanningSessionAsync(string taskId, CancellationToken ct)
|
||||||
|
=> await ResumePlanningSessionAsync(taskId, ct);
|
||||||
|
async Task IWorkerClient.DiscardPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||||
|
=> await DiscardPlanningSessionAsync(taskId, ct);
|
||||||
|
async Task IWorkerClient.FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
||||||
|
=> await FinalizePlanningSessionAsync(taskId, queueAgentTasks, ct);
|
||||||
|
async Task<int> IWorkerClient.GetPendingDraftCountAsync(string taskId, CancellationToken ct)
|
||||||
|
=> await GetPendingDraftCountAsync(taskId, ct);
|
||||||
|
|
||||||
// DTOs for deserializing hub responses
|
// DTOs for deserializing hub responses
|
||||||
private sealed class ActiveTaskDto
|
private sealed class ActiveTaskDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
||||||
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
||||||
|
|
||||||
|
// Set by the view so DeleteTaskCommand can show an error message
|
||||||
|
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
||||||
|
|
||||||
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker, IServiceProvider services)
|
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker, IServiceProvider services)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -537,9 +540,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
|
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
await using var ctx = _dbFactory.CreateDbContext();
|
try
|
||||||
var repo = new TaskRepository(ctx);
|
{
|
||||||
await repo.DeleteAsync(row.Id);
|
await using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var repo = new TaskRepository(ctx);
|
||||||
|
await repo.DeleteAsync(row.Id);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (
|
||||||
|
ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
if (ShowErrorAsync != null)
|
||||||
|
await ShowErrorAsync("This task has child tasks. Discard the planning session or delete child tasks first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (DeleteFromList != null)
|
if (DeleteFromList != null)
|
||||||
await DeleteFromList(row);
|
await DeleteFromList(row);
|
||||||
CloseDetail?.Invoke();
|
CloseDetail?.Invoke();
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private int _diffDeletions;
|
[ObservableProperty] private int _diffDeletions;
|
||||||
[ObservableProperty] private bool _dropHintAbove;
|
[ObservableProperty] private bool _dropHintAbove;
|
||||||
[ObservableProperty] private bool _dropHintBelow;
|
[ObservableProperty] private bool _dropHintBelow;
|
||||||
|
[ObservableProperty] private string? _parentTaskId;
|
||||||
|
[ObservableProperty] private bool _isExpanded = true;
|
||||||
|
|
||||||
public DateTime CreatedAt { get; init; }
|
public DateTime CreatedAt { get; init; }
|
||||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
||||||
@@ -31,6 +33,22 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public int StepsCount { get; init; }
|
public int StepsCount { get; init; }
|
||||||
public int StepsCompleted { get; init; }
|
public int StepsCompleted { get; init; }
|
||||||
|
|
||||||
|
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||||||
|
public bool IsPlanningParent => Status == TaskStatus.Planning || Status == TaskStatus.Planned;
|
||||||
|
public bool IsPlanning => Status == TaskStatus.Planning;
|
||||||
|
public bool IsPlanned => Status == TaskStatus.Planned;
|
||||||
|
public bool IsDraft => Status == TaskStatus.Draft;
|
||||||
|
|
||||||
|
public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
|
||||||
|
public bool CanResumeOrDiscardPlanning => Status == TaskStatus.Planning;
|
||||||
|
|
||||||
|
public string? PlanningBadge => Status switch
|
||||||
|
{
|
||||||
|
TaskStatus.Planning => "PLANNING",
|
||||||
|
TaskStatus.Planned => "PLANNED",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
|
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
|
||||||
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
|
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
|
||||||
public bool HasTags => Tags.Count > 0;
|
public bool HasTags => Tags.Count > 0;
|
||||||
@@ -60,6 +78,19 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(IsRunning));
|
OnPropertyChanged(nameof(IsRunning));
|
||||||
OnPropertyChanged(nameof(IsQueued));
|
OnPropertyChanged(nameof(IsQueued));
|
||||||
OnPropertyChanged(nameof(HasLiveTail));
|
OnPropertyChanged(nameof(HasLiveTail));
|
||||||
|
OnPropertyChanged(nameof(IsPlanningParent));
|
||||||
|
OnPropertyChanged(nameof(IsPlanning));
|
||||||
|
OnPropertyChanged(nameof(IsPlanned));
|
||||||
|
OnPropertyChanged(nameof(PlanningBadge));
|
||||||
|
OnPropertyChanged(nameof(IsDraft));
|
||||||
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
|
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnParentTaskIdChanged(string? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsChild));
|
||||||
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||||
@@ -91,6 +122,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
DiffAdditions = add,
|
DiffAdditions = add,
|
||||||
DiffDeletions = del,
|
DiffDeletions = del,
|
||||||
CreatedAt = t.CreatedAt,
|
CreatedAt = t.CreatedAt,
|
||||||
|
ParentTaskId = t.ParentTaskId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
@@ -13,7 +14,8 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
|||||||
public sealed partial class TasksIslandViewModel : ViewModelBase
|
public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly WorkerClient? _worker;
|
private readonly IWorkerClient? _worker;
|
||||||
|
private readonly Dictionary<string, bool> _expandedState = new();
|
||||||
private ListNavItemViewModel? _currentList;
|
private ListNavItemViewModel? _currentList;
|
||||||
private CancellationTokenSource? _loadCts;
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
@@ -41,7 +43,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _showOpenLabel;
|
[ObservableProperty] private bool _showOpenLabel;
|
||||||
[ObservableProperty] private string _completedHeader = "COMPLETED";
|
[ObservableProperty] private string _completedHeader = "COMPLETED";
|
||||||
|
|
||||||
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient? worker = null)
|
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
|
||||||
|
|
||||||
|
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
@@ -105,14 +109,35 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Regroup()
|
internal void Regroup()
|
||||||
{
|
{
|
||||||
OverdueItems.Clear();
|
OverdueItems.Clear();
|
||||||
OpenItems.Clear();
|
OpenItems.Clear();
|
||||||
CompletedItems.Clear();
|
CompletedItems.Clear();
|
||||||
|
|
||||||
var today = DateTime.Today;
|
// Restore IsExpanded from saved state
|
||||||
foreach (var r in Items)
|
foreach (var r in Items)
|
||||||
|
{
|
||||||
|
if (_expandedState.TryGetValue(r.Id, out var saved))
|
||||||
|
r.IsExpanded = saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build hierarchy-aware flat list: top-level rows interleaved with visible children.
|
||||||
|
// Items is already ordered by SortOrder from the DB query.
|
||||||
|
var topLevel = Items.Where(r => !r.IsChild);
|
||||||
|
var flat = new List<TaskRowViewModel>();
|
||||||
|
foreach (var parent in topLevel)
|
||||||
|
{
|
||||||
|
flat.Add(parent);
|
||||||
|
if (parent.IsPlanningParent && parent.IsExpanded)
|
||||||
|
{
|
||||||
|
var children = Items.Where(r => r.ParentTaskId == parent.Id);
|
||||||
|
flat.AddRange(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateTime.Today;
|
||||||
|
foreach (var r in flat)
|
||||||
{
|
{
|
||||||
if (r.Done)
|
if (r.Done)
|
||||||
CompletedItems.Add(r);
|
CompletedItems.Add(r);
|
||||||
@@ -351,8 +376,83 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Sort() { /* placeholder — UI-only */ }
|
private void Sort() { /* placeholder — UI-only */ }
|
||||||
|
|
||||||
|
public event EventHandler? OpenListSettingsRequested;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void More() { /* placeholder — UI-only */ }
|
private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || row.Status != TaskStatus.Manual) return;
|
||||||
|
try { await _worker!.StartPlanningSessionAsync(row.Id); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || !row.IsPlanningParent) return;
|
||||||
|
if (_worker is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var draftCount = await _worker.GetPendingDraftCountAsync(row.Id);
|
||||||
|
var modalVm = new UnfinishedPlanningModalViewModel
|
||||||
|
{
|
||||||
|
TaskTitle = row.Title,
|
||||||
|
DraftCount = draftCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ShowUnfinishedPlanningModal is null)
|
||||||
|
return;
|
||||||
|
await ShowUnfinishedPlanningModal(modalVm);
|
||||||
|
|
||||||
|
var choice = await modalVm.Result.Task;
|
||||||
|
|
||||||
|
switch (choice)
|
||||||
|
{
|
||||||
|
case UnfinishedPlanningModalResult.Resume:
|
||||||
|
await _worker.ResumePlanningSessionAsync(row.Id);
|
||||||
|
break;
|
||||||
|
case UnfinishedPlanningModalResult.FinalizeNow:
|
||||||
|
await _worker.FinalizePlanningSessionAsync(row.Id);
|
||||||
|
break;
|
||||||
|
case UnfinishedPlanningModalResult.Discard:
|
||||||
|
await _worker.DiscardPlanningSessionAsync(row.Id);
|
||||||
|
break;
|
||||||
|
case UnfinishedPlanningModalResult.Cancel:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
try { await _worker!.DiscardPlanningSessionAsync(row.Id); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleExpand(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : row.IsExpanded);
|
||||||
|
_expandedState[row.Id] = next;
|
||||||
|
row.IsExpanded = next;
|
||||||
|
Regroup();
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
|
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
@@ -7,33 +12,53 @@ namespace ClaudeDo.Ui.ViewModels;
|
|||||||
|
|
||||||
public sealed partial class IslandsShellViewModel : ViewModelBase
|
public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public ListsIslandViewModel Lists { get; }
|
public ListsIslandViewModel? Lists { get; }
|
||||||
public TasksIslandViewModel Tasks { get; }
|
public TasksIslandViewModel? Tasks { get; }
|
||||||
public DetailsIslandViewModel Details { get; }
|
public DetailsIslandViewModel? Details { get; }
|
||||||
public WorkerClient Worker { get; }
|
public WorkerClient? Worker { get; }
|
||||||
|
public UpdateCheckService UpdateCheck => _updateCheck;
|
||||||
|
|
||||||
public string ConnectionText =>
|
public string ConnectionText =>
|
||||||
Worker.IsConnected ? "Online"
|
Worker?.IsConnected == true ? "Online"
|
||||||
: Worker.IsReconnecting ? "Connecting…"
|
: Worker?.IsReconnecting == true ? "Connecting…"
|
||||||
: "Offline";
|
: "Offline";
|
||||||
|
|
||||||
public bool IsOffline => !Worker.IsConnected && !Worker.IsReconnecting;
|
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
|
||||||
|
|
||||||
|
private readonly UpdateCheckService _updateCheck;
|
||||||
|
private readonly InstallerLocator _installerLocator;
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isUpdateBannerVisible;
|
||||||
|
[ObservableProperty] private string? _updateBannerLatestVersion;
|
||||||
|
[ObservableProperty] private string? _inlineUpdateStatus;
|
||||||
|
private bool _bannerDismissedThisSession;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private double _windowWidth = 1280;
|
private double _windowWidth = 1280;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? _workerLogText;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private WorkerLogLevel _workerLogLevel;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isWorkerLogVisible;
|
||||||
|
|
||||||
public bool ShowDetails => WindowWidth >= 1100;
|
public bool ShowDetails => WindowWidth >= 1100;
|
||||||
public bool ShowLists => WindowWidth >= 780;
|
public bool ShowLists => WindowWidth >= 780;
|
||||||
|
|
||||||
[RelayCommand]
|
private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false };
|
||||||
private void FocusSearch() => Lists.RequestFocusSearch();
|
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void FocusAddTask() => Tasks.RequestFocusAddTask();
|
private void FocusSearch() => Lists?.RequestFocusSearch();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void FocusAddTask() => Tasks?.RequestFocusAddTask();
|
||||||
|
|
||||||
public async Task ToggleSelectedDoneAsync()
|
public async Task ToggleSelectedDoneAsync()
|
||||||
{
|
{
|
||||||
if (Tasks.SelectedTask is { } row)
|
if (Tasks?.SelectedTask is { } row)
|
||||||
await Tasks.ToggleDoneCommand.ExecuteAsync(row);
|
await Tasks.ToggleDoneCommand.ExecuteAsync(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,16 +68,44 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(ShowLists));
|
OnPropertyChanged(nameof(ShowLists));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnWorkerLogReceived(WorkerLogEntry entry)
|
||||||
|
{
|
||||||
|
var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
|
||||||
|
WorkerLogText = $"{hhmm} · {entry.Message}";
|
||||||
|
WorkerLogLevel = entry.Level;
|
||||||
|
IsWorkerLogVisible = true;
|
||||||
|
_clearTimer.Stop();
|
||||||
|
_clearTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearWorkerLog()
|
||||||
|
{
|
||||||
|
IsWorkerLogVisible = false;
|
||||||
|
WorkerLogText = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tests only — does NOT wire up events.
|
||||||
|
internal IslandsShellViewModel() { }
|
||||||
|
|
||||||
public IslandsShellViewModel(
|
public IslandsShellViewModel(
|
||||||
ListsIslandViewModel lists,
|
ListsIslandViewModel lists,
|
||||||
TasksIslandViewModel tasks,
|
TasksIslandViewModel tasks,
|
||||||
DetailsIslandViewModel details,
|
DetailsIslandViewModel details,
|
||||||
WorkerClient worker)
|
WorkerClient worker,
|
||||||
|
UpdateCheckService updateCheck,
|
||||||
|
InstallerLocator installerLocator)
|
||||||
{
|
{
|
||||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||||
|
_updateCheck = updateCheck;
|
||||||
|
_installerLocator = installerLocator;
|
||||||
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
||||||
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
||||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||||
|
Tasks.OpenListSettingsRequested += (_, _) =>
|
||||||
|
{
|
||||||
|
if (Lists.SelectedList is { } row)
|
||||||
|
Lists.OpenListSettingsCommand.Execute(row);
|
||||||
|
};
|
||||||
Details.CloseDetail = () => Tasks.SelectedTask = null;
|
Details.CloseDetail = () => Tasks.SelectedTask = null;
|
||||||
Details.DeleteFromList = row =>
|
Details.DeleteFromList = row =>
|
||||||
{
|
{
|
||||||
@@ -68,6 +121,83 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(IsOffline));
|
OnPropertyChanged(nameof(IsOffline));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
||||||
|
_clearTimer.Elapsed += (_, _) =>
|
||||||
|
{
|
||||||
|
if (Dispatcher.UIThread.CheckAccess())
|
||||||
|
ClearWorkerLog();
|
||||||
|
else
|
||||||
|
Dispatcher.UIThread.Post(ClearWorkerLog);
|
||||||
|
};
|
||||||
_ = Lists.LoadAsync();
|
_ = Lists.LoadAsync();
|
||||||
|
_updateCheck.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
|
||||||
|
{
|
||||||
|
RefreshBannerFromStatus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Fire-and-forget startup check — never block UI.
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshBannerFromStatus()
|
||||||
|
{
|
||||||
|
switch (_updateCheck.LastCheckStatus)
|
||||||
|
{
|
||||||
|
case UpdateCheckStatus.UpdateAvailable:
|
||||||
|
if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; }
|
||||||
|
UpdateBannerLatestVersion = _updateCheck.LatestVersion;
|
||||||
|
IsUpdateBannerVisible = true;
|
||||||
|
InlineUpdateStatus = null;
|
||||||
|
break;
|
||||||
|
case UpdateCheckStatus.UpToDate:
|
||||||
|
IsUpdateBannerVisible = false;
|
||||||
|
ShowInlineStatus($"You're up to date (v{_updateCheck.CurrentVersion})");
|
||||||
|
break;
|
||||||
|
case UpdateCheckStatus.CheckFailed:
|
||||||
|
ShowInlineStatus("Could not check for updates");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void ShowInlineStatus(string text)
|
||||||
|
{
|
||||||
|
InlineUpdateStatus = text;
|
||||||
|
await Task.Delay(3000);
|
||||||
|
if (InlineUpdateStatus == text) InlineUpdateStatus = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CheckForUpdatesAsync()
|
||||||
|
{
|
||||||
|
await _updateCheck.CheckNowAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void DismissBanner()
|
||||||
|
{
|
||||||
|
_bannerDismissedThisSession = true;
|
||||||
|
IsUpdateBannerVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void UpdateNow()
|
||||||
|
{
|
||||||
|
var path = _installerLocator.Find();
|
||||||
|
if (path is null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||||
|
Environment.Exit(0);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Intentionally silent — if this fails there's nothing useful to show.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
public enum UnfinishedPlanningModalResult
|
||||||
|
{
|
||||||
|
Cancel,
|
||||||
|
Resume,
|
||||||
|
FinalizeNow,
|
||||||
|
Discard,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class UnfinishedPlanningModalViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
[ObservableProperty] private string _taskTitle = "";
|
||||||
|
[ObservableProperty] private int _draftCount;
|
||||||
|
|
||||||
|
public TaskCompletionSource<UnfinishedPlanningModalResult> Result { get; } = new();
|
||||||
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
|
[RelayCommand] private void Resume() { Result.TrySetResult(UnfinishedPlanningModalResult.Resume); CloseAction?.Invoke(); }
|
||||||
|
[RelayCommand] private void FinalizeNow() { Result.TrySetResult(UnfinishedPlanningModalResult.FinalizeNow); CloseAction?.Invoke(); }
|
||||||
|
[RelayCommand] private void Discard() { Result.TrySetResult(UnfinishedPlanningModalResult.Discard); CloseAction?.Invoke(); }
|
||||||
|
[RelayCommand] private void Cancel() { Result.TrySetResult(UnfinishedPlanningModalResult.Cancel); CloseAction?.Invoke(); }
|
||||||
|
}
|
||||||
@@ -45,9 +45,49 @@ public partial class DetailsIslandView : UserControl
|
|||||||
};
|
};
|
||||||
|
|
||||||
vm.ConfirmAsync = ShowConfirmAsync;
|
vm.ConfirmAsync = ShowConfirmAsync;
|
||||||
|
vm.ShowErrorAsync = ShowErrorDialogAsync;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner == null) return;
|
||||||
|
|
||||||
|
var ok = new Button { Content = "OK", MinWidth = 90 };
|
||||||
|
|
||||||
|
var dialog = new Window
|
||||||
|
{
|
||||||
|
Title = "Error",
|
||||||
|
Width = 360,
|
||||||
|
SizeToContent = SizeToContent.Height,
|
||||||
|
CanResize = false,
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||||
|
ShowInTaskbar = false,
|
||||||
|
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||||
|
Content = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 16,
|
||||||
|
Margin = new Thickness(20),
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||||
|
new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Spacing = 8,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
Children = { ok }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ok.Click += (_, _) => dialog.Close();
|
||||||
|
|
||||||
|
await dialog.ShowDialog(owner);
|
||||||
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
||||||
{
|
{
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
|||||||
@@ -134,9 +134,9 @@
|
|||||||
CommandParameter="{Binding}"/>
|
CommandParameter="{Binding}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Border.ContextMenu>
|
</Border.ContextMenu>
|
||||||
<Grid ColumnDefinitions="20,*,Auto,Auto">
|
<Grid ColumnDefinitions="20,*,Auto">
|
||||||
<!-- Left accent bar for active state -->
|
<!-- Left accent bar for active state -->
|
||||||
<Border Grid.Column="0" Grid.ColumnSpan="4"
|
<Border Grid.Column="0" Grid.ColumnSpan="3"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
CornerRadius="8" IsHitTestVisible="False"
|
CornerRadius="8" IsHitTestVisible="False"
|
||||||
IsVisible="{Binding IsActive}">
|
IsVisible="{Binding IsActive}">
|
||||||
@@ -159,14 +159,6 @@
|
|||||||
<!-- Count -->
|
<!-- Count -->
|
||||||
<TextBlock Grid.Column="2" Classes="list-count"
|
<TextBlock Grid.Column="2" Classes="list-count"
|
||||||
Text="{Binding Count}"/>
|
Text="{Binding Count}"/>
|
||||||
<!-- Gear button -->
|
|
||||||
<Button Grid.Column="3" Classes="icon-btn"
|
|
||||||
Content="⚙"
|
|
||||||
FontSize="12"
|
|
||||||
ToolTip.Tip="Settings..."
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
|
|
||||||
CommandParameter="{Binding}"/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|||||||
@@ -15,134 +15,192 @@
|
|||||||
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
||||||
IsVisible="{Binding DropHintAbove}"/>
|
IsVisible="{Binding DropHintAbove}"/>
|
||||||
|
|
||||||
<Border Grid.Row="1" Classes="task-row"
|
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
|
||||||
Margin="0"
|
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||||
Classes.selected="{Binding IsSelected}"
|
|
||||||
Classes.done="{Binding Done}">
|
<!-- Indent track (only visible for child tasks) -->
|
||||||
<Border.ContextMenu>
|
<Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
|
||||||
<ContextMenu>
|
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
|
||||||
<MenuItem Header="Send to queue"
|
HorizontalAlignment="Right" Margin="0,4"/>
|
||||||
IsVisible="{Binding !IsQueued}"
|
</Border>
|
||||||
Click="OnSendToQueueClick"/>
|
|
||||||
<MenuItem Header="Remove from queue"
|
<!-- Main task card -->
|
||||||
|
<Border Grid.Column="1" Classes="task-row"
|
||||||
|
Margin="0"
|
||||||
|
Classes.selected="{Binding IsSelected}"
|
||||||
|
Classes.done="{Binding Done}">
|
||||||
|
<Border.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="Send to queue"
|
||||||
|
IsVisible="{Binding !IsQueued}"
|
||||||
|
Click="OnSendToQueueClick"/>
|
||||||
|
<MenuItem Header="Remove from queue"
|
||||||
|
IsVisible="{Binding IsQueued}"
|
||||||
|
Click="OnRemoveFromQueueClick"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Open planning Session"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).OpenPlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding CanOpenPlanningSession}"/>
|
||||||
|
<MenuItem Header="Resume planning Session"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ResumePlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||||
|
<MenuItem Header="Discard planning session"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).DiscardPlanningSessionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
|
||||||
|
<MenuItem Header="Clear schedule"
|
||||||
|
IsVisible="{Binding HasSchedule}"
|
||||||
|
Click="OnClearScheduleClick"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</Border.ContextMenu>
|
||||||
|
<Grid ColumnDefinitions="0,18,32,*,Auto,32" Margin="6,8,10,8">
|
||||||
|
|
||||||
|
<!-- Chevron toggle (only for planning parent tasks) -->
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
IsVisible="{Binding IsPlanningParent}"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleExpandCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
Classes="icon-btn"
|
||||||
|
Width="18" Height="18"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Panel>
|
||||||
|
<TextBlock Text="▾" FontSize="10" IsVisible="{Binding IsExpanded}"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="▸" FontSize="10" IsVisible="{Binding !IsExpanded}"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Center"/>
|
||||||
|
</Panel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Done toggle -->
|
||||||
|
<Button Grid.Column="2" Classes="flat" VerticalAlignment="Top"
|
||||||
|
Margin="0,2,0,0"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleDoneCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Ellipse Width="18" Height="18" Classes="task-check"
|
||||||
|
Classes.done="{Binding Done}"/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Title + chip row + live tail -->
|
||||||
|
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="task-title"
|
||||||
|
Text="{Binding Title}" FontSize="14"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
|
||||||
|
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
|
||||||
|
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
|
||||||
|
|
||||||
|
<!-- Badges: DRAFT and planning session -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
|
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
||||||
|
<TextBlock Text="DRAFT"/>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="badge planning" IsVisible="{Binding IsPlanning}">
|
||||||
|
<TextBlock Text="PLANNING"/>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="badge planned" IsVisible="{Binding IsPlanned}">
|
||||||
|
<TextBlock Text="PLANNED"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Chip row -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
|
||||||
|
<!-- Status chip -->
|
||||||
|
<Border Classes="chip"
|
||||||
|
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
||||||
|
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
|
||||||
|
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
|
||||||
|
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
|
||||||
|
<TextBlock Text="{Binding Status}"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Dequeue button (only when Queued) -->
|
||||||
|
<Button Classes="icon-btn dequeue-btn"
|
||||||
IsVisible="{Binding IsQueued}"
|
IsVisible="{Binding IsQueued}"
|
||||||
Click="OnRemoveFromQueueClick"/>
|
ToolTip.Tip="Remove from queue"
|
||||||
<Separator/>
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
|
||||||
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
|
CommandParameter="{Binding}">
|
||||||
<MenuItem Header="Clear schedule"
|
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
|
||||||
IsVisible="{Binding HasSchedule}"
|
</Button>
|
||||||
Click="OnClearScheduleClick"/>
|
|
||||||
</ContextMenu>
|
|
||||||
</Border.ContextMenu>
|
|
||||||
<Grid ColumnDefinitions="0,32,*,32" Margin="6,8,10,8">
|
|
||||||
|
|
||||||
<!-- Done toggle -->
|
<!-- List chip with dot -->
|
||||||
<Button Grid.Column="1" Classes="flat" VerticalAlignment="Top"
|
<Border Classes="chip chip-list">
|
||||||
Margin="0,2,0,0"
|
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleDoneCommand}"
|
<Ellipse Width="6" Height="6"
|
||||||
CommandParameter="{Binding}">
|
Fill="{DynamicResource MossBrush}"
|
||||||
<Ellipse Width="18" Height="18" Classes="task-check"
|
VerticalAlignment="Center"/>
|
||||||
Classes.done="{Binding Done}"/>
|
<TextBlock Text="{Binding ListName}"/>
|
||||||
</Button>
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Title + chip row + live tail -->
|
<!-- Branch chip -->
|
||||||
<StackPanel Grid.Column="2" Spacing="6" VerticalAlignment="Center">
|
<Border Classes="chip chip-branch" IsVisible="{Binding HasBranch}">
|
||||||
<TextBlock Classes="task-title"
|
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
Text="{Binding Title}" FontSize="14"
|
<PathIcon Width="10" Height="10"
|
||||||
Foreground="{DynamicResource TextBrush}"
|
Data="{StaticResource Icon.GitBranch}"
|
||||||
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Text="{Binding Branch}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Chip row -->
|
<!-- Diff chip -->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<Border Classes="chip chip-diff" IsVisible="{Binding HasDiff}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="diff-add" Text="{Binding DiffAdditionsText}"/>
|
||||||
|
<TextBlock Classes="diff-del" Text="{Binding DiffDeletionsText}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Status chip -->
|
<!-- Tag chips -->
|
||||||
<Border Classes="chip"
|
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
|
||||||
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
<ItemsControl.ItemsPanel>
|
||||||
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
|
<ItemsPanelTemplate>
|
||||||
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
|
<StackPanel Orientation="Horizontal" Spacing="6"/>
|
||||||
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
|
</ItemsPanelTemplate>
|
||||||
<TextBlock Text="{Binding Status}"/>
|
</ItemsControl.ItemsPanel>
|
||||||
</Border>
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Classes="chip chip-tag">
|
||||||
|
<TextBlock Text="{Binding}"/>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Dequeue button (only when Queued) -->
|
<!-- Live-tail row (visible when running + has tail) -->
|
||||||
<Button Classes="icon-btn dequeue-btn"
|
<Border Classes="task-live-tail" IsVisible="{Binding HasLiveTail}">
|
||||||
IsVisible="{Binding IsQueued}"
|
<StackPanel Spacing="3">
|
||||||
ToolTip.Tip="Remove from queue"
|
<TextBlock Text="{Binding LiveTail}"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
|
TextTrimming="CharacterEllipsis" MaxLines="1"/>
|
||||||
CommandParameter="{Binding}">
|
<Grid Height="3" HorizontalAlignment="Stretch">
|
||||||
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
|
<Rectangle Fill="{DynamicResource Surface3Brush}"
|
||||||
</Button>
|
HorizontalAlignment="Stretch" RadiusX="1.5" RadiusY="1.5"/>
|
||||||
|
<Rectangle Fill="{DynamicResource MossBrush}"
|
||||||
<!-- List chip with dot -->
|
HorizontalAlignment="Left" Width="60" RadiusX="1.5" RadiusY="1.5"/>
|
||||||
<Border Classes="chip chip-list">
|
</Grid>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
|
||||||
<Ellipse Width="6" Height="6"
|
|
||||||
Fill="{DynamicResource MossBrush}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="{Binding ListName}"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Branch chip -->
|
|
||||||
<Border Classes="chip chip-branch" IsVisible="{Binding HasBranch}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
|
||||||
<PathIcon Width="10" Height="10"
|
|
||||||
Data="{StaticResource Icon.GitBranch}"
|
|
||||||
Foreground="{DynamicResource TextDimBrush}"/>
|
|
||||||
<TextBlock Text="{Binding Branch}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Diff chip -->
|
|
||||||
<Border Classes="chip chip-diff" IsVisible="{Binding HasDiff}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
|
||||||
<TextBlock Classes="diff-add" Text="{Binding DiffAdditionsText}"/>
|
|
||||||
<TextBlock Classes="diff-del" Text="{Binding DiffDeletionsText}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Tag chips -->
|
|
||||||
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
|
|
||||||
<ItemsControl.ItemsPanel>
|
|
||||||
<ItemsPanelTemplate>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6"/>
|
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<Border Classes="chip chip-tag">
|
|
||||||
<TextBlock Text="{Binding}"/>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Live-tail row (visible when running + has tail) -->
|
<!-- Star toggle -->
|
||||||
<Border Classes="task-live-tail" IsVisible="{Binding HasLiveTail}">
|
<Button Grid.Column="5" Classes="icon-btn star-btn"
|
||||||
<StackPanel Spacing="3">
|
Classes.on="{Binding IsStarred}"
|
||||||
<TextBlock Text="{Binding LiveTail}"
|
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||||
TextTrimming="CharacterEllipsis" MaxLines="1"/>
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
|
||||||
<Grid Height="3" HorizontalAlignment="Stretch">
|
CommandParameter="{Binding}">
|
||||||
<Rectangle Fill="{DynamicResource Surface3Brush}"
|
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
|
||||||
HorizontalAlignment="Stretch" RadiusX="1.5" RadiusY="1.5"/>
|
</Button>
|
||||||
<Rectangle Fill="{DynamicResource MossBrush}"
|
</Grid>
|
||||||
HorizontalAlignment="Left" Width="60" RadiusX="1.5" RadiusY="1.5"/>
|
</Border>
|
||||||
</Grid>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Star toggle -->
|
</Grid>
|
||||||
<Button Grid.Column="3" Classes="icon-btn star-btn"
|
|
||||||
Classes.on="{Binding IsStarred}"
|
|
||||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
|
|
||||||
CommandParameter="{Binding}">
|
|
||||||
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Below-row indicator: only expands when visible (used for the last row of a section) -->
|
<!-- Below-row indicator: only expands when visible (used for the last row of a section) -->
|
||||||
<Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}">
|
<Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}">
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
ToolTip.Tip="Show completed">
|
ToolTip.Tip="Show completed">
|
||||||
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/>
|
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="icon-btn" Command="{Binding MoreCommand}" ToolTip.Tip="More">
|
<Button Classes="icon-btn" Command="{Binding OpenListSettingsCommand}" ToolTip.Tip="List settings">
|
||||||
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.MoreHorizontal}"/>
|
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Settings}"/>
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using Avalonia.Input;
|
|||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
using ClaudeDo.Ui.Views.Modals;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands;
|
namespace ClaudeDo.Ui.Views.Islands;
|
||||||
|
|
||||||
@@ -19,7 +21,19 @@ public partial class TasksIslandView : UserControl
|
|||||||
DataContextChanged += (_, _) =>
|
DataContextChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
if (DataContext is TasksIslandViewModel vm)
|
if (DataContext is TasksIslandViewModel vm)
|
||||||
|
{
|
||||||
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
|
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
|
||||||
|
vm.ShowUnfinishedPlanningModal = async (modalVm) =>
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner is null) { modalVm.CancelCommand.Execute(null); return; }
|
||||||
|
var modal = new UnfinishedPlanningModalView { DataContext = modalVm };
|
||||||
|
// Closing via the OS title-bar (if ever enabled) also resolves the TCS.
|
||||||
|
modal.Closed += (_, _) => modalVm.CancelCommand.Execute(null);
|
||||||
|
await modal.ShowDialog(owner);
|
||||||
|
// ShowDialog completes once the window is closed (CloseAction or OS close).
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
|
||||||
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
|
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
|
||||||
|
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
||||||
x:Class="ClaudeDo.Ui.Views.MainWindow"
|
x:Class="ClaudeDo.Ui.Views.MainWindow"
|
||||||
x:DataType="vm:IslandsShellViewModel"
|
x:DataType="vm:IslandsShellViewModel"
|
||||||
Title="ClaudeDo"
|
Title="ClaudeDo"
|
||||||
@@ -12,12 +13,15 @@
|
|||||||
SystemDecorations="BorderOnly"
|
SystemDecorations="BorderOnly"
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
ExtendClientAreaTitleBarHeightHint="-1">
|
ExtendClientAreaTitleBarHeightHint="-1">
|
||||||
|
<Window.Resources>
|
||||||
|
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
|
||||||
|
</Window.Resources>
|
||||||
<Window.KeyBindings>
|
<Window.KeyBindings>
|
||||||
<KeyBinding Gesture="OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
<KeyBinding Gesture="OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
||||||
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
||||||
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
|
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
|
||||||
</Window.KeyBindings>
|
</Window.KeyBindings>
|
||||||
<Grid RowDefinitions="36,*,22">
|
<Grid RowDefinitions="36,Auto,*,22">
|
||||||
<!-- Custom title bar -->
|
<!-- Custom title bar -->
|
||||||
<Border Grid.Row="0"
|
<Border Grid.Row="0"
|
||||||
Background="{DynamicResource DeepBrush}"
|
Background="{DynamicResource DeepBrush}"
|
||||||
@@ -54,6 +58,17 @@
|
|||||||
Foreground="{DynamicResource TextDimBrush}"
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
LetterSpacing="1.4"
|
LetterSpacing="1.4"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
|
<!-- Help menu -->
|
||||||
|
<Menu Margin="12,0,0,0"
|
||||||
|
Background="Transparent"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<MenuItem Header="Help"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}">
|
||||||
|
<MenuItem Header="Check for updates"
|
||||||
|
Command="{Binding CheckForUpdatesCommand}"/>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Middle: draggable strip -->
|
<!-- Middle: draggable strip -->
|
||||||
@@ -77,8 +92,47 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Update banner -->
|
||||||
|
<Border Grid.Row="1"
|
||||||
|
Background="{DynamicResource DeepBrush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
Padding="14,6"
|
||||||
|
IsVisible="{Binding IsUpdateBannerVisible}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
FontSize="12">
|
||||||
|
<Run Text="Update available: v"/>
|
||||||
|
<Run Text="{Binding UpdateCheck.CurrentVersion}"/>
|
||||||
|
<Run Text=" → v"/>
|
||||||
|
<Run Text="{Binding UpdateBannerLatestVersion}"/>
|
||||||
|
</TextBlock>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
Padding="10,3"
|
||||||
|
Content="Update now"
|
||||||
|
Command="{Binding UpdateNowCommand}"/>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Padding="10,3"
|
||||||
|
Content="Dismiss"
|
||||||
|
Command="{Binding DismissBannerCommand}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Inline update status (appears at right of banner row when no banner) -->
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,14,0"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"
|
||||||
|
Text="{Binding InlineUpdateStatus}"
|
||||||
|
IsVisible="{Binding InlineUpdateStatus, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
|
|
||||||
<!-- Background gradient layer -->
|
<!-- Background gradient layer -->
|
||||||
<Border Grid.Row="1">
|
<Border Grid.Row="2">
|
||||||
<Border.Background>
|
<Border.Background>
|
||||||
<RadialGradientBrush Center="50%,50%" GradientOrigin="50%,50%" RadiusX="70%" RadiusY="70%">
|
<RadialGradientBrush Center="50%,50%" GradientOrigin="50%,50%" RadiusX="70%" RadiusY="70%">
|
||||||
<GradientStop Offset="0" Color="{StaticResource DeepColor}" />
|
<GradientStop Offset="0" Color="{StaticResource DeepColor}" />
|
||||||
@@ -88,7 +142,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Three islands -->
|
<!-- Three islands -->
|
||||||
<Grid Grid.Row="1" Margin="7" ColumnDefinitions="260,*,320">
|
<Grid Grid.Row="2" Margin="7" ColumnDefinitions="260,*,320">
|
||||||
<Border Grid.Column="0" Classes="island" Margin="7">
|
<Border Grid.Column="0" Classes="island" Margin="7">
|
||||||
<islands:ListsIslandView DataContext="{Binding Lists}"/>
|
<islands:ListsIslandView DataContext="{Binding Lists}"/>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -102,36 +156,42 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Footer: connection status -->
|
<!-- Footer: connection status -->
|
||||||
<Border Grid.Row="2"
|
<Border Grid.Row="3"
|
||||||
Background="{DynamicResource DeepBrush}"
|
Background="{DynamicResource DeepBrush}"
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
BorderThickness="0,1,0,0">
|
BorderThickness="0,1,0,0">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7"
|
<DockPanel LastChildFill="True" Margin="14,0">
|
||||||
VerticalAlignment="Center" Margin="14,0">
|
<!-- Left: connection pill -->
|
||||||
<Ellipse Width="7" Height="7" Fill="#4CAF50"
|
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="7"
|
||||||
IsVisible="{Binding Worker.IsConnected}"/>
|
VerticalAlignment="Center">
|
||||||
<Ellipse Width="7" Height="7" Fill="#FFA726"
|
<Ellipse Width="7" Height="7" Fill="#4CAF50"
|
||||||
IsVisible="{Binding Worker.IsReconnecting}"/>
|
IsVisible="{Binding Worker.IsConnected}"/>
|
||||||
<Ellipse Width="7" Height="7" Fill="#EF5350"
|
<Ellipse Width="7" Height="7" Fill="#FFA726"
|
||||||
IsVisible="{Binding IsOffline}"/>
|
IsVisible="{Binding Worker.IsReconnecting}"/>
|
||||||
<TextBlock Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
|
<Ellipse Width="7" Height="7" Fill="#EF5350"
|
||||||
|
IsVisible="{Binding IsOffline}"/>
|
||||||
|
<TextBlock Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="10"
|
||||||
|
LetterSpacing="1.4"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Right: worker log line -->
|
||||||
|
<TextBlock DockPanel.Dock="Right"
|
||||||
|
Text="{Binding WorkerLogText}"
|
||||||
|
IsVisible="{Binding IsWorkerLogVisible}"
|
||||||
|
Foreground="{Binding WorkerLogLevel, Converter={StaticResource WorkerLogLevelToBrush}}"
|
||||||
FontFamily="{DynamicResource MonoFont}"
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
FontSize="10"
|
FontSize="10"
|
||||||
LetterSpacing="1.4"
|
LetterSpacing="1.4"
|
||||||
Foreground="{DynamicResource TextDimBrush}"
|
TextTrimming="CharacterEllipsis"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<TextBlock Text="·"
|
|
||||||
FontFamily="{DynamicResource MonoFont}"
|
<!-- Spacer between pill and log -->
|
||||||
FontSize="10"
|
<Panel/>
|
||||||
Foreground="{DynamicResource TextFaintBrush}"
|
</DockPanel>
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="WORKER"
|
|
||||||
FontFamily="{DynamicResource MonoFont}"
|
|
||||||
FontSize="10"
|
|
||||||
LetterSpacing="1.4"
|
|
||||||
Foreground="{DynamicResource TextFaintBrush}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:ClaudeDo.Ui.ViewModels.Modals"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Modals.UnfinishedPlanningModalView"
|
||||||
|
x:DataType="vm:UnfinishedPlanningModalViewModel"
|
||||||
|
Title="Unfinished planning session"
|
||||||
|
Width="440" Height="200"
|
||||||
|
CanResize="False"
|
||||||
|
SystemDecorations="None"
|
||||||
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource SurfaceBrush}">
|
||||||
|
|
||||||
|
<Window.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
|
||||||
|
</Window.KeyBindings>
|
||||||
|
|
||||||
|
<Window.Styles>
|
||||||
|
<Style Selector="Button.primary">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource DeepBrush}"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
</Style>
|
||||||
|
</Window.Styles>
|
||||||
|
|
||||||
|
<Border Background="{DynamicResource SurfaceBrush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="1">
|
||||||
|
<Grid RowDefinitions="36,*,52">
|
||||||
|
|
||||||
|
<!-- Title bar -->
|
||||||
|
<Border Grid.Row="0"
|
||||||
|
x:Name="TitleBar"
|
||||||
|
Background="{DynamicResource DeepBrush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
PointerPressed="TitleBar_PointerPressed">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
|
||||||
|
<TextBlock Text="UNFINISHED PLANNING SESSION"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="11"
|
||||||
|
LetterSpacing="1.4"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Classes="icon-btn"
|
||||||
|
Content="✕"
|
||||||
|
FontSize="12"
|
||||||
|
Command="{Binding CancelCommand}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="20,16" Spacing="8">
|
||||||
|
<TextBlock Text="{Binding TaskTitle}"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Foreground="{DynamicResource TextDimBrush}">
|
||||||
|
<Run Text="{Binding DraftCount}"/>
|
||||||
|
<Run Text=" draft task(s) waiting to be finalized."/>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Border Grid.Row="2"
|
||||||
|
Background="{DynamicResource DeepBrush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,1,0,0">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
Margin="16,0">
|
||||||
|
<Button Content="Discard" Command="{Binding DiscardCommand}" MinWidth="80"/>
|
||||||
|
<Button Content="Finalize" Command="{Binding FinalizeNowCommand}" MinWidth="80"/>
|
||||||
|
<Button Content="Resume" Command="{Binding ResumeCommand}" Classes="primary" MinWidth="80"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Modals;
|
||||||
|
|
||||||
|
public partial class UnfinishedPlanningModalView : Window
|
||||||
|
{
|
||||||
|
public UnfinishedPlanningModalView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContextChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
if (DataContext is ViewModels.Modals.UnfinishedPlanningModalViewModel vm)
|
||||||
|
vm.CloseAction = () => Close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
BeginMoveDrag(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Hub;
|
namespace ClaudeDo.Worker.Hub;
|
||||||
@@ -28,4 +29,7 @@ public sealed class HubBroadcaster
|
|||||||
|
|
||||||
public Task RunCreated(string taskId, int runNumber, bool isRetry) =>
|
public Task RunCreated(string taskId, int runNumber, bool isRetry) =>
|
||||||
_hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry);
|
_hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry);
|
||||||
|
|
||||||
|
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
|
||||||
|
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
|||||||
|
|
||||||
builder.Services.AddSingleton(cfg);
|
builder.Services.AddSingleton(cfg);
|
||||||
builder.Services.AddHostedService<StaleTaskRecovery>();
|
builder.Services.AddHostedService<StaleTaskRecovery>();
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR().AddJsonProtocol(options =>
|
||||||
|
{
|
||||||
|
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||||
|
});
|
||||||
|
|
||||||
// Runner stack.
|
// Runner stack.
|
||||||
builder.Services.AddSingleton<IClaudeProcess, ClaudeProcess>();
|
builder.Services.AddSingleton<IClaudeProcess, ClaudeProcess>();
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public sealed class TaskRunner
|
|||||||
list = await listRepo.GetByIdAsync(task.ListId, ct);
|
list = await listRepo.GetByIdAsync(task.ListId, ct);
|
||||||
if (list is null)
|
if (list is null)
|
||||||
{
|
{
|
||||||
await MarkFailed(task.Id, slot, "List not found.");
|
await MarkFailed(task.Id, task.Title, slot, "List not found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
|
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
|
||||||
@@ -67,12 +67,13 @@ public sealed class TaskRunner
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
wtCtx = await _wtManager.CreateAsync(task, list, ct);
|
wtCtx = await _wtManager.CreateAsync(task, list, ct);
|
||||||
|
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
|
||||||
runDir = wtCtx.WorktreePath;
|
runDir = wtCtx.WorktreePath;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to create worktree for task {TaskId}", task.Id);
|
_logger.LogError(ex, "Failed to create worktree for task {TaskId}", task.Id);
|
||||||
await MarkFailed(task.Id, slot, $"Worktree creation failed: {ex.Message}");
|
await MarkFailed(task.Id, task.Title, slot, $"Worktree creation failed: {ex.Message}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +105,7 @@ public sealed class TaskRunner
|
|||||||
var prompt = sb.ToString();
|
var prompt = sb.ToString();
|
||||||
|
|
||||||
// Run 1.
|
// Run 1.
|
||||||
var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct);
|
var result = await RunOnceAsync(task.Id, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct);
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
@@ -119,7 +120,7 @@ public sealed class TaskRunner
|
|||||||
var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
|
var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
|
||||||
var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues.";
|
var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues.";
|
||||||
|
|
||||||
var retryResult = await RunOnceAsync(task.Id, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
|
var retryResult = await RunOnceAsync(task.Id, task.Title, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
|
||||||
|
|
||||||
if (retryResult.IsSuccess)
|
if (retryResult.IsSuccess)
|
||||||
{
|
{
|
||||||
@@ -127,12 +128,12 @@ public sealed class TaskRunner
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await HandleFailure(task.Id, slot, retryResult);
|
await HandleFailure(task.Id, task.Title, slot, retryResult);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await HandleFailure(task.Id, slot, result);
|
await HandleFailure(task.Id, task.Title, slot, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,12 +142,12 @@ public sealed class TaskRunner
|
|||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Task {TaskId} was cancelled", task.Id);
|
_logger.LogInformation("Task {TaskId} was cancelled", task.Id);
|
||||||
await MarkFailed(task.Id, slot, "Task cancelled.");
|
await MarkFailed(task.Id, task.Title, slot, "Task cancelled.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id);
|
_logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id);
|
||||||
await MarkFailed(task.Id, slot, $"Unhandled error: {ex.Message}");
|
await MarkFailed(task.Id, task.Title, slot, $"Unhandled error: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +205,7 @@ public sealed class TaskRunner
|
|||||||
await _broadcaster.TaskStarted(slot, taskId, now);
|
await _broadcaster.TaskStarted(slot, taskId, now);
|
||||||
|
|
||||||
var nextRunNumber = lastRun.RunNumber + 1;
|
var nextRunNumber = lastRun.RunNumber + 1;
|
||||||
var result = await RunOnceAsync(taskId, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
|
var result = await RunOnceAsync(taskId, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
@@ -212,14 +213,14 @@ public sealed class TaskRunner
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await HandleFailure(taskId, slot, result);
|
await HandleFailure(taskId, task.Title, slot, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<RunResult> RunOnceAsync(
|
private async Task<RunResult> RunOnceAsync(
|
||||||
string taskId, string slot, string runDir, ClaudeRunConfig config,
|
string taskId, string taskTitle, string slot, string runDir, ClaudeRunConfig config,
|
||||||
int runNumber, bool isRetry, string prompt, CancellationToken ct)
|
int runNumber, bool isRetry, string prompt, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var runId = Guid.NewGuid().ToString();
|
var runId = Guid.NewGuid().ToString();
|
||||||
@@ -250,6 +251,7 @@ public sealed class TaskRunner
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await _broadcaster.WorkerLog($"Started Claude for \"{taskTitle}\"", WorkerLogLevel.Info, DateTime.UtcNow);
|
||||||
var result = await _claude.RunAsync(
|
var result = await _claude.RunAsync(
|
||||||
arguments,
|
arguments,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -315,7 +317,10 @@ public sealed class TaskRunner
|
|||||||
{
|
{
|
||||||
var committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
|
var committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
|
||||||
if (committed)
|
if (committed)
|
||||||
|
{
|
||||||
|
await _broadcaster.WorkerLog($"Committed changes in \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
|
||||||
await _broadcaster.WorktreeUpdated(task.Id);
|
await _broadcaster.WorktreeUpdated(task.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal DB write uses CancellationToken.None so the task status
|
// Terminal DB write uses CancellationToken.None so the task status
|
||||||
@@ -326,13 +331,16 @@ public sealed class TaskRunner
|
|||||||
{
|
{
|
||||||
var taskRepo = new TaskRepository(context);
|
var taskRepo = new TaskRepository(context);
|
||||||
await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
||||||
|
if (task.ParentTaskId is not null)
|
||||||
|
await taskRepo.TryCompleteParentAsync(task.ParentTaskId, CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||||
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
||||||
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
|
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
|
||||||
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
|
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleFailure(string taskId, string slot, RunResult result)
|
private async Task HandleFailure(string taskId, string taskTitle, string slot, RunResult result)
|
||||||
{
|
{
|
||||||
// Intentionally does not accept a CancellationToken: this is the
|
// Intentionally does not accept a CancellationToken: this is the
|
||||||
// terminal write for a failed task and must always be persisted.
|
// terminal write for a failed task and must always be persisted.
|
||||||
@@ -340,11 +348,15 @@ public sealed class TaskRunner
|
|||||||
using var context = _dbFactory.CreateDbContext();
|
using var context = _dbFactory.CreateDbContext();
|
||||||
var taskRepo = new TaskRepository(context);
|
var taskRepo = new TaskRepository(context);
|
||||||
await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
|
await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
|
||||||
|
var justFailed = await taskRepo.GetByIdAsync(taskId, CancellationToken.None);
|
||||||
|
if (justFailed?.ParentTaskId is not null)
|
||||||
|
await taskRepo.TryCompleteParentAsync(justFailed.ParentTaskId, CancellationToken.None);
|
||||||
|
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
|
||||||
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
|
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
|
||||||
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
|
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MarkFailed(string taskId, string slot, string error)
|
private async Task MarkFailed(string taskId, string taskTitle, string slot, string error)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -353,6 +365,10 @@ public sealed class TaskRunner
|
|||||||
using var context = _dbFactory.CreateDbContext();
|
using var context = _dbFactory.CreateDbContext();
|
||||||
var taskRepo = new TaskRepository(context);
|
var taskRepo = new TaskRepository(context);
|
||||||
await taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
|
await taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
|
||||||
|
var justFailed = await taskRepo.GetByIdAsync(taskId, CancellationToken.None);
|
||||||
|
if (justFailed?.ParentTaskId is not null)
|
||||||
|
await taskRepo.TryCompleteParentAsync(justFailed.ParentTaskId, CancellationToken.None);
|
||||||
|
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
|
||||||
await _broadcaster.TaskFinished(slot, taskId, "failed", now);
|
await _broadcaster.TaskFinished(slot, taskId, "failed", now);
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ public sealed class TaskMergeService
|
|||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
|
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
|
||||||
taskId, wt.BranchName, targetBranch, removeWorktree);
|
taskId, wt.BranchName, targetBranch, removeWorktree);
|
||||||
|
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||||
|
|
||||||
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
|
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ public sealed class TaskResetService
|
|||||||
if (wt is not null && wt.State == WorktreeState.Active && list.WorkingDir is not null)
|
if (wt is not null && wt.State == WorktreeState.Active && list.WorkingDir is not null)
|
||||||
{
|
{
|
||||||
await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);
|
await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);
|
||||||
|
await _broadcaster.WorkerLog($"Discarded worktree for \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||||
worktreeChanged = true;
|
worktreeChanged = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,5 +65,6 @@ public sealed class TaskResetService
|
|||||||
await _broadcaster.WorktreeUpdated(taskId);
|
await _broadcaster.WorktreeUpdated(taskId);
|
||||||
|
|
||||||
_logger.LogInformation("Reset task {TaskId} to Manual (worktree discarded: {Discarded})", taskId, worktreeChanged);
|
_logger.LogInformation("Reset task {TaskId} to Manual (worktree discarded: {Discarded})", taskId, worktreeChanged);
|
||||||
|
await _broadcaster.WorkerLog($"Reset \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.IO;
|
|||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
using ClaudeDo.Installer.Steps;
|
using ClaudeDo.Installer.Steps;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Tests;
|
namespace ClaudeDo.Installer.Tests;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Tests;
|
namespace ClaudeDo.Installer.Tests;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Tests;
|
namespace ClaudeDo.Releases.Tests;
|
||||||
|
|
||||||
public sealed class ChecksumVerifierTests : IDisposable
|
public sealed class ChecksumVerifierTests : IDisposable
|
||||||
{
|
{
|
||||||
20
tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj
Normal file
20
tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Tests;
|
namespace ClaudeDo.Releases.Tests;
|
||||||
|
|
||||||
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
|
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Tests;
|
namespace ClaudeDo.Releases.Tests;
|
||||||
|
|
||||||
public sealed class ReleaseClientTests
|
public sealed class ReleaseClientTests
|
||||||
{
|
{
|
||||||
256
tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
Normal file
256
tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Releases.Tests;
|
||||||
|
|
||||||
|
public class SelfUpdaterAssetMatchingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FindInstallerAsset_PicksInstallerExeByPattern()
|
||||||
|
{
|
||||||
|
var assets = new[]
|
||||||
|
{
|
||||||
|
new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "https://x/app.zip", 10),
|
||||||
|
new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst.exe", 20),
|
||||||
|
new ReleaseAsset("checksums.txt", "https://x/checks", 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = SelfUpdater.FindInstallerAsset(assets);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("ClaudeDo.Installer-0.3.0.exe", result!.Asset.Name);
|
||||||
|
Assert.Equal("0.3.0", result.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindInstallerAsset_ReturnsNullWhenAbsent()
|
||||||
|
{
|
||||||
|
var assets = new[]
|
||||||
|
{
|
||||||
|
new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "https://x/app.zip", 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Null(SelfUpdater.FindInstallerAsset(assets));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindInstallerAsset_IgnoresAppZipThatContainsInstaller()
|
||||||
|
{
|
||||||
|
var assets = new[]
|
||||||
|
{
|
||||||
|
new ReleaseAsset("ClaudeDo.Installer.Portable-0.3.0.zip", "https://x/1", 1),
|
||||||
|
new ReleaseAsset("not-the-installer.exe", "https://x/2", 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Null(SelfUpdater.FindInstallerAsset(assets));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SelfUpdaterDecisionTests
|
||||||
|
{
|
||||||
|
private sealed class FakeReleaseClient : IReleaseClient
|
||||||
|
{
|
||||||
|
public GiteaRelease? Release { get; set; }
|
||||||
|
public bool Throw { get; set; }
|
||||||
|
|
||||||
|
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (Throw) throw new HttpRequestException("boom");
|
||||||
|
return Task.FromResult(Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException("not used in decision tests");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Decide_NoRelease_NoUpdate()
|
||||||
|
{
|
||||||
|
var client = new FakeReleaseClient { Release = null };
|
||||||
|
var d = await SelfUpdater.DecideUpdateAsync(client, currentVersion: "0.1.0", CancellationToken.None);
|
||||||
|
Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Decide_NetworkError_NoUpdate()
|
||||||
|
{
|
||||||
|
var client = new FakeReleaseClient { Throw = true };
|
||||||
|
var d = await SelfUpdater.DecideUpdateAsync(client, "0.1.0", CancellationToken.None);
|
||||||
|
Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Decide_OlderLatest_NoUpdate()
|
||||||
|
{
|
||||||
|
var client = new FakeReleaseClient
|
||||||
|
{
|
||||||
|
Release = new GiteaRelease("v0.1.0", "rel", new[]
|
||||||
|
{
|
||||||
|
new ReleaseAsset("ClaudeDo.Installer-0.1.0.exe", "u", 1),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None);
|
||||||
|
Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Decide_NewerLatestWithAsset_UpdateAvailable()
|
||||||
|
{
|
||||||
|
var client = new FakeReleaseClient
|
||||||
|
{
|
||||||
|
Release = new GiteaRelease("v0.3.0", "rel", new[]
|
||||||
|
{
|
||||||
|
new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x", 20),
|
||||||
|
new ReleaseAsset("checksums.txt", "https://checks", 1),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None);
|
||||||
|
Assert.Equal(SelfUpdateDecisionKind.UpdateAvailable, d.Kind);
|
||||||
|
Assert.Equal("0.3.0", d.LatestVersion);
|
||||||
|
Assert.NotNull(d.InstallerAsset);
|
||||||
|
Assert.NotNull(d.ChecksumsAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Decide_NewerLatestButNoInstallerAsset_NoUpdate()
|
||||||
|
{
|
||||||
|
var client = new FakeReleaseClient
|
||||||
|
{
|
||||||
|
Release = new GiteaRelease("v0.3.0", "rel", new[]
|
||||||
|
{
|
||||||
|
new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "u", 20),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None);
|
||||||
|
Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SelfUpdaterReplaceSelfTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempDir;
|
||||||
|
|
||||||
|
public SelfUpdaterReplaceSelfTests()
|
||||||
|
{
|
||||||
|
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Replace_DeletesOldAndCopiesCurrent()
|
||||||
|
{
|
||||||
|
var oldPath = Path.Combine(_tempDir, "old.exe");
|
||||||
|
var currentPath = Path.Combine(_tempDir, "current.exe");
|
||||||
|
await File.WriteAllTextAsync(oldPath, "OLD");
|
||||||
|
await File.WriteAllTextAsync(currentPath, "NEW");
|
||||||
|
|
||||||
|
var relaunchedWith = "";
|
||||||
|
var result = await SelfUpdater.HandleReplaceSelfAsync(
|
||||||
|
oldPath: oldPath,
|
||||||
|
currentExePath: currentPath,
|
||||||
|
launchProcess: path => { relaunchedWith = path; return true; },
|
||||||
|
maxWaitMs: 500);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(oldPath, relaunchedWith);
|
||||||
|
Assert.Equal("NEW", await File.ReadAllTextAsync(oldPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Replace_TimesOutWhenFileStaysLocked_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var oldPath = Path.Combine(_tempDir, "locked.exe");
|
||||||
|
var currentPath = Path.Combine(_tempDir, "current.exe");
|
||||||
|
await File.WriteAllTextAsync(oldPath, "OLD");
|
||||||
|
await File.WriteAllTextAsync(currentPath, "NEW");
|
||||||
|
|
||||||
|
// Hold an exclusive lock across the wait window.
|
||||||
|
using var lockStream = new FileStream(oldPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
|
||||||
|
|
||||||
|
var result = await SelfUpdater.HandleReplaceSelfAsync(
|
||||||
|
oldPath: oldPath,
|
||||||
|
currentExePath: currentPath,
|
||||||
|
launchProcess: _ => true,
|
||||||
|
maxWaitMs: 200);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SelfUpdaterDownloadTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempDir;
|
||||||
|
|
||||||
|
public SelfUpdaterDownloadTests()
|
||||||
|
{
|
||||||
|
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } }
|
||||||
|
|
||||||
|
private sealed class StubReleaseClient : IReleaseClient
|
||||||
|
{
|
||||||
|
public string FileContent { get; set; } = "";
|
||||||
|
public string ChecksumsBody { get; set; } = "";
|
||||||
|
|
||||||
|
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult<GiteaRelease?>(null);
|
||||||
|
|
||||||
|
public async Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (url.EndsWith("checksums.txt", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(destPath, ChecksumsBody, ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(destPath, FileContent, ct);
|
||||||
|
}
|
||||||
|
progress.Report(FileContent.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Download_MatchingChecksum_ReturnsPath()
|
||||||
|
{
|
||||||
|
var content = "FAKE-INSTALLER-BINARY";
|
||||||
|
var hash = Sha256Hex(content);
|
||||||
|
var client = new StubReleaseClient
|
||||||
|
{
|
||||||
|
FileContent = content,
|
||||||
|
ChecksumsBody = $"{hash} ClaudeDo.Installer-0.3.0.exe\n",
|
||||||
|
};
|
||||||
|
var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", content.Length);
|
||||||
|
var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100);
|
||||||
|
|
||||||
|
var path = await SelfUpdater.DownloadAndVerifyAsync(
|
||||||
|
client, installer, checksums, _tempDir, new Progress<long>(_ => { }), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(path);
|
||||||
|
Assert.Equal(content, await File.ReadAllTextAsync(path!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Download_ChecksumMismatch_ReturnsNull()
|
||||||
|
{
|
||||||
|
var client = new StubReleaseClient
|
||||||
|
{
|
||||||
|
FileContent = "real",
|
||||||
|
ChecksumsBody = "deadbeef" + new string('0', 56) + " ClaudeDo.Installer-0.3.0.exe\n",
|
||||||
|
};
|
||||||
|
var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", 4);
|
||||||
|
var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100);
|
||||||
|
|
||||||
|
var path = await SelfUpdater.DownloadAndVerifyAsync(
|
||||||
|
client, installer, checksums, _tempDir, new Progress<long>(_ => { }), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Sha256Hex(string s)
|
||||||
|
{
|
||||||
|
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||||
|
return Convert.ToHexString(sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(s))).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs
Normal file
30
tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace ClaudeDo.Releases.Tests;
|
||||||
|
|
||||||
|
public class VersionComparerTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("0.2.0", "0.1.0", true, false)]
|
||||||
|
[InlineData("0.2.0", "0.2.0", false, false)]
|
||||||
|
[InlineData("0.1.0", "0.2.0", false, false)]
|
||||||
|
[InlineData("v0.2.0", "0.1.0", true, false)]
|
||||||
|
[InlineData("0.2.0", "v0.1.0", true, false)]
|
||||||
|
[InlineData("1.0.0.0", "0.99.99.99", true, false)]
|
||||||
|
public void Compare_ParseableVersions(string latest, string current, bool expectedNewer, bool expectedUnparseable)
|
||||||
|
{
|
||||||
|
var result = VersionComparer.Compare(latest, current);
|
||||||
|
Assert.Equal(expectedNewer, result.IsNewer);
|
||||||
|
Assert.Equal(expectedUnparseable, result.Unparseable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("0.2.0-beta", "0.1.0")]
|
||||||
|
[InlineData("0.2.0", "0.1.0-alpha")]
|
||||||
|
[InlineData("garbage", "0.1.0")]
|
||||||
|
[InlineData("", "0.1.0")]
|
||||||
|
public void Compare_UnparseableReturnsNotNewer(string latest, string current)
|
||||||
|
{
|
||||||
|
var result = VersionComparer.Compare(latest, current);
|
||||||
|
Assert.False(result.IsNewer);
|
||||||
|
Assert.True(result.Unparseable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="12.0.0" />
|
||||||
|
<PackageReference Include="Avalonia.Headless" Version="12.0.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests;
|
||||||
|
|
||||||
|
public class IslandsShellViewModelWorkerLogTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Receiving_event_sets_text_level_and_visible()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
var at = new DateTime(2026, 4, 23, 14, 32, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
vm.OnWorkerLogReceived(new WorkerLogEntry("Created worktree for \"X\"", WorkerLogLevel.Info, at));
|
||||||
|
|
||||||
|
Assert.True(vm.IsWorkerLogVisible);
|
||||||
|
Assert.Equal(WorkerLogLevel.Info, vm.WorkerLogLevel);
|
||||||
|
Assert.Contains("Created worktree for \"X\"", vm.WorkerLogText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Second_event_replaces_first()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
vm.OnWorkerLogReceived(new WorkerLogEntry("first", WorkerLogLevel.Info, DateTime.UtcNow));
|
||||||
|
vm.OnWorkerLogReceived(new WorkerLogEntry("second", WorkerLogLevel.Success, DateTime.UtcNow));
|
||||||
|
|
||||||
|
Assert.Contains("second", vm.WorkerLogText);
|
||||||
|
Assert.Equal(WorkerLogLevel.Success, vm.WorkerLogLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearWorkerLog_hides_line()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
vm.OnWorkerLogReceived(new WorkerLogEntry("msg", WorkerLogLevel.Info, DateTime.UtcNow));
|
||||||
|
|
||||||
|
vm.ClearWorkerLog();
|
||||||
|
|
||||||
|
Assert.False(vm.IsWorkerLogVisible);
|
||||||
|
Assert.Null(vm.WorkerLogText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Text_is_formatted_as_HHmm_dot_message_local_time()
|
||||||
|
{
|
||||||
|
var vm = new IslandsShellViewModel();
|
||||||
|
var utc = new DateTime(2026, 4, 23, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
var expectedLocalHhmm = utc.ToLocalTime().ToString("HH:mm");
|
||||||
|
|
||||||
|
vm.OnWorkerLogReceived(new WorkerLogEntry("hello", WorkerLogLevel.Info, utc));
|
||||||
|
|
||||||
|
Assert.StartsWith(expectedLocalHhmm + " · ", vm.WorkerLogText);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs
Normal file
58
tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.Services;
|
||||||
|
|
||||||
|
public class InstallerLocatorTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root;
|
||||||
|
|
||||||
|
public InstallerLocatorTests()
|
||||||
|
{
|
||||||
|
_root = Path.Combine(Path.GetTempPath(), "ClaudeDo.Ui.Tests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { try { Directory.Delete(_root, true); } catch { } }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Find_WalkUpFromAppDir_ToInstallJsonSibling()
|
||||||
|
{
|
||||||
|
var installDir = Path.Combine(_root, "ClaudeDo");
|
||||||
|
var appDir = Path.Combine(installDir, "app");
|
||||||
|
var uninstallerDir = Path.Combine(installDir, "uninstaller");
|
||||||
|
Directory.CreateDirectory(appDir);
|
||||||
|
Directory.CreateDirectory(uninstallerDir);
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(installDir, "install.json"), "{}");
|
||||||
|
var installerPath = Path.Combine(uninstallerDir, "ClaudeDo.Installer.exe");
|
||||||
|
File.WriteAllText(installerPath, "x");
|
||||||
|
|
||||||
|
var locator = new InstallerLocator();
|
||||||
|
var found = locator.FindByWalkingUp(appDir);
|
||||||
|
|
||||||
|
Assert.Equal(installerPath, found);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Find_ReturnsNullWhenNoInstallJson()
|
||||||
|
{
|
||||||
|
var appDir = Path.Combine(_root, "somewhere", "app");
|
||||||
|
Directory.CreateDirectory(appDir);
|
||||||
|
|
||||||
|
var locator = new InstallerLocator();
|
||||||
|
Assert.Null(locator.FindByWalkingUp(appDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Find_ReturnsNullWhenInstallerMissingFromUninstallerDir()
|
||||||
|
{
|
||||||
|
var installDir = Path.Combine(_root, "ClaudeDo");
|
||||||
|
var appDir = Path.Combine(installDir, "app");
|
||||||
|
Directory.CreateDirectory(appDir);
|
||||||
|
Directory.CreateDirectory(Path.Combine(installDir, "uninstaller"));
|
||||||
|
File.WriteAllText(Path.Combine(installDir, "install.json"), "{}");
|
||||||
|
|
||||||
|
var locator = new InstallerLocator();
|
||||||
|
Assert.Null(locator.FindByWalkingUp(appDir));
|
||||||
|
}
|
||||||
|
}
|
||||||
62
tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs
Normal file
62
tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.Services;
|
||||||
|
|
||||||
|
public class UpdateCheckServiceTests
|
||||||
|
{
|
||||||
|
private sealed class FakeReleaseClient : IReleaseClient
|
||||||
|
{
|
||||||
|
public GiteaRelease? Release { get; set; }
|
||||||
|
public bool Throw { get; set; }
|
||||||
|
|
||||||
|
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (Throw) throw new HttpRequestException();
|
||||||
|
return Task.FromResult(Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Check_NewerRelease_SetsUpdateAvailable()
|
||||||
|
{
|
||||||
|
var svc = new UpdateCheckService(new FakeReleaseClient
|
||||||
|
{
|
||||||
|
Release = new GiteaRelease("v0.3.0", "r", new[] { new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "u", 1) }),
|
||||||
|
},
|
||||||
|
currentVersion: "0.1.0");
|
||||||
|
|
||||||
|
await svc.CheckNowAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(UpdateCheckStatus.UpdateAvailable, svc.LastCheckStatus);
|
||||||
|
Assert.True(svc.IsUpdateAvailable);
|
||||||
|
Assert.Equal("0.3.0", svc.LatestVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Check_SameRelease_SetsUpToDate()
|
||||||
|
{
|
||||||
|
var svc = new UpdateCheckService(new FakeReleaseClient
|
||||||
|
{
|
||||||
|
Release = new GiteaRelease("v0.1.0", "r", new[] { new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "u", 1) }),
|
||||||
|
},
|
||||||
|
currentVersion: "0.1.0");
|
||||||
|
|
||||||
|
await svc.CheckNowAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(UpdateCheckStatus.UpToDate, svc.LastCheckStatus);
|
||||||
|
Assert.False(svc.IsUpdateAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Check_NetworkError_SetsCheckFailedButDoesNotThrow()
|
||||||
|
{
|
||||||
|
var svc = new UpdateCheckService(new FakeReleaseClient { Throw = true }, "0.1.0");
|
||||||
|
await svc.CheckNowAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(UpdateCheckStatus.CheckFailed, svc.LastCheckStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Converters;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests;
|
||||||
|
|
||||||
|
public class WorkerLogLevelToBrushConverterTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(WorkerLogLevel.Success, "#FF4CAF50")]
|
||||||
|
[InlineData(WorkerLogLevel.Warn, "#FFFFA726")]
|
||||||
|
[InlineData(WorkerLogLevel.Error, "#FFEF5350")]
|
||||||
|
public void Convert_maps_level_to_expected_brush_color(WorkerLogLevel level, string expectedArgb)
|
||||||
|
{
|
||||||
|
var converter = new WorkerLogLevelToBrushConverter();
|
||||||
|
|
||||||
|
var result = converter.Convert(level, typeof(IBrush), null, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
var solid = Assert.IsType<SolidColorBrush>(result);
|
||||||
|
Assert.Equal(expectedArgb.ToLowerInvariant(), $"#{solid.Color.ToUInt32():X8}".ToLowerInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_info_returns_a_brush_fallback_when_no_app()
|
||||||
|
{
|
||||||
|
var converter = new WorkerLogLevelToBrushConverter();
|
||||||
|
|
||||||
|
var result = converter.Convert(WorkerLogLevel.Info, typeof(IBrush), null, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
Assert.IsAssignableFrom<IBrush>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_unknown_value_returns_unset()
|
||||||
|
{
|
||||||
|
var converter = new WorkerLogLevelToBrushConverter();
|
||||||
|
|
||||||
|
var result = converter.Convert("not a level", typeof(IBrush), null, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
Assert.Equal(AvaloniaProperty.UnsetValue, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Repositories;
|
||||||
|
|
||||||
|
public sealed class TaskRepositoryParentCompletionTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
|
||||||
|
public TaskRepositoryParentCompletionTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||||
|
|
||||||
|
private async Task<string> ListAsync()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaskEntity> PlannedParentAsync(string listId)
|
||||||
|
{
|
||||||
|
var parent = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "p",
|
||||||
|
Status = TaskStatus.Planned,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaskEntity> ChildAsync(string listId, string parentId, TaskStatus status)
|
||||||
|
{
|
||||||
|
var child = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "c",
|
||||||
|
Status = status,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
ParentTaskId = parentId,
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteParentAsync_AllChildrenDone_ParentBecomesDone()
|
||||||
|
{
|
||||||
|
var listId = await ListAsync();
|
||||||
|
var parent = await PlannedParentAsync(listId);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
||||||
|
|
||||||
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, loaded!.Status);
|
||||||
|
Assert.NotNull(loaded.FinishedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteParentAsync_OneFailedRestDone_ParentBecomesFailed()
|
||||||
|
{
|
||||||
|
var listId = await ListAsync();
|
||||||
|
var parent = await PlannedParentAsync(listId);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Failed);
|
||||||
|
|
||||||
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Failed, loaded!.Status);
|
||||||
|
Assert.NotNull(loaded.FinishedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteParentAsync_OneStillRunning_ParentStaysPlanned()
|
||||||
|
{
|
||||||
|
var listId = await ListAsync();
|
||||||
|
var parent = await PlannedParentAsync(listId);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Running);
|
||||||
|
|
||||||
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
||||||
|
Assert.Null(loaded.FinishedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteParentAsync_ChildStillDraft_ParentStaysPlanned()
|
||||||
|
{
|
||||||
|
var listId = await ListAsync();
|
||||||
|
var parent = await PlannedParentAsync(listId);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Draft);
|
||||||
|
|
||||||
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryCompleteParentAsync_ParentIsNotPlanned_NoChange()
|
||||||
|
{
|
||||||
|
var listId = await ListAsync();
|
||||||
|
var parent = await PlannedParentAsync(listId);
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync("UPDATE tasks SET status = 'planning' WHERE id = {0}", parent.Id);
|
||||||
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
||||||
|
|
||||||
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Repositories;
|
||||||
|
|
||||||
|
public sealed class TaskRepositoryPlanningTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
private readonly TagRepository _tags;
|
||||||
|
|
||||||
|
public TaskRepositoryPlanningTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
_tags = new TagRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateListAsync(string? id = null)
|
||||||
|
{
|
||||||
|
var listId = id ?? Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId,
|
||||||
|
Name = "Test List",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
return listId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Manual, string? parentId = null) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "t",
|
||||||
|
Status = status,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "feat",
|
||||||
|
ParentTaskId = parentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetChildrenAsync_ReturnsOnlyDirectChildren_Sorted()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Planning);
|
||||||
|
parent.Title = "parent";
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
|
||||||
|
var childA = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id);
|
||||||
|
childA.Title = "a";
|
||||||
|
await _tasks.AddAsync(childA);
|
||||||
|
childA.SortOrder = 1;
|
||||||
|
await _tasks.UpdateAsync(childA);
|
||||||
|
|
||||||
|
var childB = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id);
|
||||||
|
childB.Title = "b";
|
||||||
|
await _tasks.AddAsync(childB);
|
||||||
|
childB.SortOrder = 0;
|
||||||
|
await _tasks.UpdateAsync(childB);
|
||||||
|
|
||||||
|
var unrelated = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(unrelated);
|
||||||
|
|
||||||
|
var children = await _tasks.GetChildrenAsync(parent.Id);
|
||||||
|
|
||||||
|
Assert.Equal(2, children.Count);
|
||||||
|
Assert.Equal("b", children[0].Title);
|
||||||
|
Assert.Equal("a", children[1].Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildAsync_CreatesDraftUnderParent()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Planning);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
|
||||||
|
var child = await _tasks.CreateChildAsync(
|
||||||
|
parent.Id,
|
||||||
|
title: "child title",
|
||||||
|
description: "child desc",
|
||||||
|
tagNames: new[] { "agent" },
|
||||||
|
commitType: "feat");
|
||||||
|
|
||||||
|
Assert.Equal(TaskStatus.Draft, child.Status);
|
||||||
|
Assert.Equal(parent.Id, child.ParentTaskId);
|
||||||
|
Assert.Equal(listId, child.ListId);
|
||||||
|
Assert.Equal("child title", child.Title);
|
||||||
|
Assert.Equal("child desc", child.Description);
|
||||||
|
Assert.Equal("feat", child.CommitType);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(child.Id);
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal(TaskStatus.Draft, loaded!.Status);
|
||||||
|
|
||||||
|
var tags = await _tasks.GetTagsAsync(child.Id);
|
||||||
|
Assert.Contains(tags, t => t.Name == "agent");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildAsync_ThrowsIfParentNotFound()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
_ = listId; // just to create the DB
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
_tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetPlanningStartedAsync_ManualTask_TransitionsToPlanning()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var task = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
|
||||||
|
var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-abc");
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(TaskStatus.Planning, result!.Status);
|
||||||
|
Assert.Equal("tok-abc", result.PlanningSessionToken);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
||||||
|
Assert.Equal("tok-abc", loaded.PlanningSessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetPlanningStartedAsync_NonManualTask_ReturnsNull()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var task = MakeTask(listId, TaskStatus.Queued);
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
|
||||||
|
var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-xyz");
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Queued, loaded!.Status);
|
||||||
|
Assert.Null(loaded.PlanningSessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdatePlanningSessionIdAsync_StoresClaudeSessionId()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var task = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
await _tasks.SetPlanningStartedAsync(task.Id, "tok");
|
||||||
|
|
||||||
|
await _tasks.UpdatePlanningSessionIdAsync(task.Id, "claude-session-42");
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal("claude-session-42", loaded!.PlanningSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FindByPlanningTokenAsync_ReturnsTask_WhenTokenMatches()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var task = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
await _tasks.SetPlanningStartedAsync(task.Id, "unique-token-123");
|
||||||
|
|
||||||
|
var found = await _tasks.FindByPlanningTokenAsync("unique-token-123");
|
||||||
|
|
||||||
|
Assert.NotNull(found);
|
||||||
|
Assert.Equal(task.Id, found!.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FindByPlanningTokenAsync_ReturnsNull_WhenTokenUnknown()
|
||||||
|
{
|
||||||
|
var found = await _tasks.FindByPlanningTokenAsync("no-such-token");
|
||||||
|
Assert.Null(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FinalizePlanningAsync_TransitionsDraftsAndParent()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||||
|
|
||||||
|
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, tagNames: new[] { "agent" }, commitType: null);
|
||||||
|
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, tagNames: null, commitType: null);
|
||||||
|
|
||||||
|
var count = await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true);
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
|
||||||
|
var c1Loaded = await _tasks.GetByIdAsync(c1.Id);
|
||||||
|
var c2Loaded = await _tasks.GetByIdAsync(c2.Id);
|
||||||
|
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
|
||||||
|
Assert.Equal(TaskStatus.Queued, c1Loaded!.Status);
|
||||||
|
Assert.Equal(TaskStatus.Manual, c2Loaded!.Status);
|
||||||
|
Assert.Equal(TaskStatus.Planned, parentLoaded!.Status);
|
||||||
|
Assert.NotNull(parentLoaded.PlanningFinalizedAt);
|
||||||
|
Assert.Null(parentLoaded.PlanningSessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FinalizePlanningAsync_QueueAgentTasksFalse_AllToManual()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||||
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: new[] { "agent" }, commitType: null);
|
||||||
|
|
||||||
|
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false);
|
||||||
|
|
||||||
|
var cLoaded = await _tasks.GetByIdAsync(c.Id);
|
||||||
|
Assert.Equal(TaskStatus.Manual, cLoaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FinalizePlanningAsync_ParentWithAgentListTag_ChildIsQueued()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
||||||
|
await _lists.AddTagAsync(listId, agentTagId);
|
||||||
|
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||||
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: null, commitType: null);
|
||||||
|
|
||||||
|
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true);
|
||||||
|
|
||||||
|
var cLoaded = await _tasks.GetByIdAsync(c.Id);
|
||||||
|
Assert.Equal(TaskStatus.Queued, cLoaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanningAsync_DeletesDraftsAndResetsParent()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||||
|
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42");
|
||||||
|
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||||
|
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||||
|
|
||||||
|
var ok = await _tasks.DiscardPlanningAsync(parent.Id);
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Null(await _tasks.GetByIdAsync(c1.Id));
|
||||||
|
Assert.Null(await _tasks.GetByIdAsync(c2.Id));
|
||||||
|
|
||||||
|
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Manual, parentLoaded!.Status);
|
||||||
|
Assert.Null(parentLoaded.PlanningSessionId);
|
||||||
|
Assert.Null(parentLoaded.PlanningSessionToken);
|
||||||
|
Assert.Null(parentLoaded.PlanningFinalizedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanningAsync_OnNonPlanningTask_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var task = MakeTask(listId, TaskStatus.Manual);
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
|
||||||
|
var ok = await _tasks.DiscardPlanningAsync(task.Id);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_ParentWithChildren_ThrowsOrDoesNotDelete()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var parent = MakeTask(listId, TaskStatus.Planning);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
|
||||||
|
// ExecuteDelete bypasses EF change tracking, so SQLite's FK enforcement
|
||||||
|
// (foreign_keys = ON, set by ClaudeDoDbContext) throws SqliteException directly.
|
||||||
|
await Assert.ThrowsAsync<Microsoft.Data.Sqlite.SqliteException>(async () =>
|
||||||
|
{
|
||||||
|
await _tasks.DeleteAsync(parent.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
var stillThere = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.NotNull(stillThere);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetNextQueuedAgentTask_SkipsDraftPlanningPlanned()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
||||||
|
|
||||||
|
async Task<TaskEntity> T(TaskStatus s, bool withTag, string? parent = null)
|
||||||
|
{
|
||||||
|
var t = MakeTask(listId, s, parentId: parent);
|
||||||
|
await _tasks.AddAsync(t);
|
||||||
|
if (withTag) await _tasks.AddTagAsync(t.Id, agentTagId);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
var planning = await T(TaskStatus.Planning, withTag: true);
|
||||||
|
var planned = await T(TaskStatus.Planned, withTag: true);
|
||||||
|
var draft = await T(TaskStatus.Draft, withTag: true, parent: planning.Id);
|
||||||
|
var queued = await T(TaskStatus.Queued, withTag: true);
|
||||||
|
|
||||||
|
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
|
||||||
|
|
||||||
|
Assert.NotNull(picked);
|
||||||
|
Assert.Equal(queued.Id, picked!.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Runner;
|
||||||
|
|
||||||
|
public sealed class TaskRunnerParentCompletionTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
|
||||||
|
public TaskRunnerParentCompletionTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChildMarkedDone_LastOne_ParentFinalized()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
|
||||||
|
var parent = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "p",
|
||||||
|
Status = TaskStatus.Planned,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
|
||||||
|
var c1 = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "c1",
|
||||||
|
Status = TaskStatus.Done,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
ParentTaskId = parent.Id,
|
||||||
|
};
|
||||||
|
var c2 = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "c2",
|
||||||
|
Status = TaskStatus.Running,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
ParentTaskId = parent.Id,
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(c1);
|
||||||
|
await _tasks.AddAsync(c2);
|
||||||
|
|
||||||
|
// Simulate the runner finishing the second child:
|
||||||
|
await _tasks.MarkDoneAsync(c2.Id, DateTime.UtcNow, "done");
|
||||||
|
if (c2.ParentTaskId is not null)
|
||||||
|
await _tasks.TryCompleteParentAsync(c2.ParentTaskId);
|
||||||
|
|
||||||
|
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, parentLoaded!.Status);
|
||||||
|
Assert.NotNull(parentLoaded.FinishedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Xunit;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.UiVm;
|
||||||
|
|
||||||
|
public class TaskRowViewModelPlanningTests
|
||||||
|
{
|
||||||
|
private static TaskRowViewModel MakeRow(TaskStatus status, string? parentTaskId = null)
|
||||||
|
=> new TaskRowViewModel { Id = "t", Status = status, ParentTaskId = parentTaskId };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Draft_Status_SetsIsChildFlag_WhenParentIdIsNotNull()
|
||||||
|
{
|
||||||
|
var vm = MakeRow(TaskStatus.Draft, "parent-id");
|
||||||
|
Assert.True(vm.IsChild);
|
||||||
|
Assert.False(vm.IsPlanningParent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Planning_Status_SetsIsPlanningParent()
|
||||||
|
{
|
||||||
|
var vm = MakeRow(TaskStatus.Planning);
|
||||||
|
Assert.True(vm.IsPlanningParent);
|
||||||
|
Assert.False(vm.IsChild);
|
||||||
|
Assert.Equal("PLANNING", vm.PlanningBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Planned_Status_ShowsPlannedBadge()
|
||||||
|
{
|
||||||
|
var vm = MakeRow(TaskStatus.Planned);
|
||||||
|
Assert.True(vm.IsPlanningParent);
|
||||||
|
Assert.Equal("PLANNED", vm.PlanningBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonPlanningStatus_NoBadge()
|
||||||
|
{
|
||||||
|
var vm = MakeRow(TaskStatus.Manual);
|
||||||
|
Assert.False(vm.IsPlanningParent);
|
||||||
|
Assert.Null(vm.PlanningBadge);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Xunit;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.UiVm;
|
||||||
|
|
||||||
|
// ── Fake worker client ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sealed class FakeWorkerClient : IWorkerClient
|
||||||
|
{
|
||||||
|
public int StartPlanningCalls { get; private set; }
|
||||||
|
public int ResumePlanningCalls { get; private set; }
|
||||||
|
public int DiscardPlanningCalls { get; private set; }
|
||||||
|
public int FinalizePlanningCalls { get; private set; }
|
||||||
|
public int WakeQueueCalls { get; private set; }
|
||||||
|
|
||||||
|
public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; }
|
||||||
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; }
|
||||||
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }
|
||||||
|
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) { DiscardPlanningCalls++; return Task.CompletedTask; }
|
||||||
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { FinalizePlanningCalls++; return Task.CompletedTask; }
|
||||||
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
|
||||||
|
|
||||||
|
file static class VmFactory
|
||||||
|
{
|
||||||
|
// Minimal SQLite :memory: factory — never actually called in these tests
|
||||||
|
// (we seed Items directly), but required by the VM constructor.
|
||||||
|
private static IDbContextFactory<ClaudeDoDbContext> NullDbFactory()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite("DataSource=:memory:")
|
||||||
|
.Options;
|
||||||
|
return new NullDbContextFactory(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullDbContextFactory(DbContextOptions<ClaudeDoDbContext> opts)
|
||||||
|
: IDbContextFactory<ClaudeDoDbContext>
|
||||||
|
{
|
||||||
|
public ClaudeDoDbContext CreateDbContext() => new(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (TasksIslandViewModel vm, FakeWorkerClient worker) Create(
|
||||||
|
IEnumerable<TaskRowViewModel> rows)
|
||||||
|
{
|
||||||
|
var worker = new FakeWorkerClient();
|
||||||
|
var vm = new TasksIslandViewModel(NullDbFactory(), worker);
|
||||||
|
foreach (var r in rows)
|
||||||
|
vm.Items.Add(r);
|
||||||
|
vm.Regroup();
|
||||||
|
return (vm, worker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class TasksIslandViewModelPlanningTests
|
||||||
|
{
|
||||||
|
private static TaskRowViewModel MakeRow(string id, TaskStatus status, string? parentId = null, int sortOrder = 0)
|
||||||
|
=> new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToggleExpand_CollapsesChildrenOfPlanningParent()
|
||||||
|
{
|
||||||
|
var parent = MakeRow("p1", TaskStatus.Planning);
|
||||||
|
var child1 = MakeRow("c1", TaskStatus.Draft, "p1");
|
||||||
|
var child2 = MakeRow("c2", TaskStatus.Draft, "p1");
|
||||||
|
|
||||||
|
var (vm, _) = VmFactory.Create([parent, child1, child2]);
|
||||||
|
|
||||||
|
// Initially expanded — children visible in OpenItems
|
||||||
|
Assert.Contains(child1, vm.OpenItems);
|
||||||
|
Assert.Contains(child2, vm.OpenItems);
|
||||||
|
|
||||||
|
// Collapse the parent
|
||||||
|
vm.ToggleExpandCommand.Execute(parent);
|
||||||
|
|
||||||
|
// Children should no longer appear
|
||||||
|
Assert.DoesNotContain(child1, vm.OpenItems);
|
||||||
|
Assert.DoesNotContain(child2, vm.OpenItems);
|
||||||
|
// Parent still present
|
||||||
|
Assert.Contains(parent, vm.OpenItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenPlanningSession_IgnoresNonManualRow()
|
||||||
|
{
|
||||||
|
var row = MakeRow("t1", TaskStatus.Queued);
|
||||||
|
var (vm, worker) = VmFactory.Create([row]);
|
||||||
|
|
||||||
|
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
|
||||||
|
|
||||||
|
Assert.Equal(0, worker.StartPlanningCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenPlanningSession_CallsWorkerForManualRow()
|
||||||
|
{
|
||||||
|
var row = MakeRow("t1", TaskStatus.Manual);
|
||||||
|
var (vm, worker) = VmFactory.Create([row]);
|
||||||
|
|
||||||
|
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
|
||||||
|
|
||||||
|
Assert.Equal(1, worker.StartPlanningCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToggleExpand_ExpandsCollapsedParentAgain()
|
||||||
|
{
|
||||||
|
var parent = MakeRow("p1", TaskStatus.Planned);
|
||||||
|
var child = MakeRow("c1", TaskStatus.Draft, "p1");
|
||||||
|
|
||||||
|
var (vm, _) = VmFactory.Create([parent, child]);
|
||||||
|
|
||||||
|
// Collapse
|
||||||
|
vm.ToggleExpandCommand.Execute(parent);
|
||||||
|
Assert.DoesNotContain(child, vm.OpenItems);
|
||||||
|
|
||||||
|
// Re-expand
|
||||||
|
vm.ToggleExpandCommand.Execute(parent);
|
||||||
|
Assert.Contains(child, vm.OpenItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user