Compare commits
18 Commits
a4e313dbad
...
a135485339
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a135485339 | ||
|
|
3c420acd54 | ||
|
|
5ced1b97a6 | ||
|
|
1344beba56 | ||
|
|
c8c8bb4a47 | ||
|
|
6f725d12f5 | ||
|
|
9952ff98f2 | ||
|
|
4a6d96b90e | ||
|
|
2690332d13 | ||
|
|
31218fc205 | ||
|
|
cc01871407 | ||
|
|
e70ae7f6ce | ||
|
|
1830273a9d | ||
|
|
1a10e6fa09 | ||
|
|
df57c2bc05 | ||
|
|
990be09bd7 | ||
|
|
e275f67a5e | ||
|
|
ff3de1d100 |
@@ -4,7 +4,9 @@
|
|||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"mcp__plugin_context-mode_context-mode__batch_execute",
|
"mcp__plugin_context-mode_context-mode__batch_execute",
|
||||||
"mcp__plugin_context-mode_context-mode__execute"
|
"mcp__plugin_context-mode_context-mode__execute",
|
||||||
|
"mcp__plugin_context7_context7__query-docs",
|
||||||
|
"mcp__plugin_context-mode_context-mode__search"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
852
docs/superpowers/plans/2026-04-23-default-agents.md
Normal file
852
docs/superpowers/plans/2026-04-23-default-agents.md
Normal file
@@ -0,0 +1,852 @@
|
|||||||
|
# Default Agents 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:** Ship ClaudeDo with 6 default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher) that seed into `~/.todo-app/agents/` on first launch, with a "Restore defaults" button in the settings modal.
|
||||||
|
|
||||||
|
**Architecture:** Bundled `.md` files in `src/ClaudeDo.Worker/DefaultAgents/` are copied to the Worker output folder. A new `DefaultAgentSeeder` service copies any missing file into the user's agents dir — run once at startup, and again on demand via a new `WorkerHub.RestoreDefaultAgents` method invoked by a button in `SettingsModalView`.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8 / ASP.NET Core / SignalR / Avalonia 12 / CommunityToolkit.Mvvm / xUnit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md`
|
||||||
|
- `src/ClaudeDo.Worker/DefaultAgents/test-writer.md`
|
||||||
|
- `src/ClaudeDo.Worker/DefaultAgents/debugger.md`
|
||||||
|
- `src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md`
|
||||||
|
- `src/ClaudeDo.Worker/DefaultAgents/explorer.md`
|
||||||
|
- `src/ClaudeDo.Worker/DefaultAgents/researcher.md`
|
||||||
|
- `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — add Content item group for `DefaultAgents\*.md`
|
||||||
|
- `src/ClaudeDo.Worker/Program.cs` — register seeder, run `SeedMissingAsync()` once at startup
|
||||||
|
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — inject `DefaultAgentSeeder`, add `RestoreDefaultAgents` method
|
||||||
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — add `RestoreDefaultAgentsAsync` method + `SeedResultDto` record
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` — add `RestoreDefaultAgentsCommand`
|
||||||
|
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` — add button section
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs` — add seeder integration test
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Bundle default agent markdown files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md`
|
||||||
|
- Create: `src/ClaudeDo.Worker/DefaultAgents/test-writer.md`
|
||||||
|
- Create: `src/ClaudeDo.Worker/DefaultAgents/debugger.md`
|
||||||
|
- Create: `src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md`
|
||||||
|
- Create: `src/ClaudeDo.Worker/DefaultAgents/explorer.md`
|
||||||
|
- Create: `src/ClaudeDo.Worker/DefaultAgents/researcher.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `code-reviewer.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: code-reviewer
|
||||||
|
description: Reviews code changes for bugs, logic errors, and convention violations. Flags only high-confidence issues.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a code reviewer. Your job is to inspect the diff for real problems, not nitpicks.
|
||||||
|
|
||||||
|
Focus on:
|
||||||
|
- Logic errors, off-by-one bugs, null/empty handling
|
||||||
|
- Broken invariants, race conditions, resource leaks
|
||||||
|
- Violations of the project's established conventions (read nearby code first)
|
||||||
|
- Missing error handling at system boundaries (external input, IO, network)
|
||||||
|
|
||||||
|
Skip:
|
||||||
|
- Style preferences the codebase doesn't enforce
|
||||||
|
- Speculative "what if" concerns
|
||||||
|
- Renaming for its own sake
|
||||||
|
|
||||||
|
Output: a short list of concrete issues with file:line references. If the diff is clean, say so in one sentence. Do not rewrite the code — call out the problem and let the implementer fix it.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write `test-writer.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: test-writer
|
||||||
|
description: Generates unit and integration tests for existing or new code. Follows the project's test patterns and frameworks.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a test-writer. Your job is to write focused, useful tests for code under review.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Read the target code and identify the observable behavior.
|
||||||
|
2. Read existing tests nearby to match the framework, fixtures, naming, and assertion style.
|
||||||
|
3. Write tests covering the happy path, boundary conditions, and the specific failure modes that matter.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- One behavior per test. Clear Arrange/Act/Assert.
|
||||||
|
- No tests for private implementation details — exercise public API.
|
||||||
|
- No mocks where real objects are cheap (in-memory DBs, temp dirs).
|
||||||
|
- Skip trivially-correct tests (getter returns what you set).
|
||||||
|
|
||||||
|
Output: the test file(s) ready to compile, matching the project's conventions. Include the command to run them.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `debugger.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: debugger
|
||||||
|
description: Systematic root-cause analysis for bugs, test failures, and unexpected behavior. Hypothesize, isolate, verify.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a debugger. You do NOT guess at fixes — you find the root cause first.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Reproduce. Get a minimal, deterministic repro. If you can't reproduce it, say so and stop.
|
||||||
|
2. Isolate. Narrow the failing path (bisect, binary search, or tracing).
|
||||||
|
3. Hypothesize. State a specific, falsifiable cause.
|
||||||
|
4. Verify. Prove the hypothesis by observation (logs, debugger, targeted print) — not by "this seems likely".
|
||||||
|
5. Fix at the root, not the symptom. If the only fix is a workaround, explain why.
|
||||||
|
|
||||||
|
Anti-patterns to avoid:
|
||||||
|
- Making changes to "see if it works"
|
||||||
|
- Adding try/catch to silence errors
|
||||||
|
- Declaring the bug fixed without reproducing the fix
|
||||||
|
|
||||||
|
Output: repro steps, root cause, and the minimal fix. Include evidence (log excerpt, command output) that proves the cause.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write `security-reviewer.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: security-reviewer
|
||||||
|
description: Audits code for OWASP-class security issues — auth, injection, input handling, secret exposure.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a security reviewer. Focus on real, exploitable weaknesses — not theoretical hardening.
|
||||||
|
|
||||||
|
Check for:
|
||||||
|
- Injection: SQL, command, path traversal, XSS, template injection
|
||||||
|
- Auth: missing authorization, token handling, session fixation
|
||||||
|
- Input validation at system boundaries (HTTP, files, IPC)
|
||||||
|
- Secrets: hardcoded credentials, tokens in logs, leaked env vars
|
||||||
|
- Unsafe deserialization, XXE, SSRF
|
||||||
|
- Cryptography misuse (custom crypto, weak algorithms, fixed IVs)
|
||||||
|
|
||||||
|
Ignore:
|
||||||
|
- Internal trust-boundary assumptions the project already documents
|
||||||
|
- Defense-in-depth ideas with no concrete attack path
|
||||||
|
|
||||||
|
Output: a prioritized list — severity, file:line, the exploit path, the fix. If nothing is wrong, say so plainly.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Write `explorer.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: explorer
|
||||||
|
description: Fast codebase navigation — find files, search for patterns, answer "where/how" questions. Terse output.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an explorer. Your job is to find things in the codebase quickly and report back concisely.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- Glob/Grep for searches
|
||||||
|
- Read only for files you need to quote from
|
||||||
|
|
||||||
|
Do NOT:
|
||||||
|
- Refactor, edit, or "improve" anything
|
||||||
|
- Read files that aren't relevant to the question
|
||||||
|
- Dump raw tool output — summarize
|
||||||
|
|
||||||
|
Output style:
|
||||||
|
- Lead with the answer in one sentence.
|
||||||
|
- Back it up with file:line references.
|
||||||
|
- If you found nothing, say "no match" and what you searched for.
|
||||||
|
|
||||||
|
Keep responses short. The caller wants facts, not prose.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Write `researcher.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: researcher
|
||||||
|
description: General-purpose research and analysis for non-code tasks — summarize docs, investigate questions, draft prose.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a researcher. You handle tasks that don't fit the code-review/test/debug shape.
|
||||||
|
|
||||||
|
Good fits:
|
||||||
|
- Summarizing documents, specs, or long outputs
|
||||||
|
- Investigating an open question (what does X do, how does Y work, what are the tradeoffs)
|
||||||
|
- Drafting non-code text (release notes, emails, docs)
|
||||||
|
- Analyzing structured data (logs, CSV, JSON) and reporting findings
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Restate the task in one sentence so you know what "done" looks like.
|
||||||
|
2. Gather just enough information — stop when you can answer, not when you run out of sources.
|
||||||
|
3. Distinguish facts ("the file says X") from inference ("so likely Y").
|
||||||
|
4. Cite sources (file:line, URL, log excerpt) for every claim.
|
||||||
|
|
||||||
|
Output: direct answer first, supporting evidence second. Keep it short unless asked for depth.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/DefaultAgents/
|
||||||
|
git commit -m "feat(worker): add bundled default agent definitions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Wire bundled agents into build output
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add Content item group for DefaultAgents**
|
||||||
|
|
||||||
|
Edit `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`. After the existing `<ItemGroup>` blocks and before the final `<PropertyGroup>`, add:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="DefaultAgents\*.md">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the worker and verify the files land in output**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
Then verify output:
|
||||||
|
|
||||||
|
Run: `ls src/ClaudeDo.Worker/bin/Debug/net8.0/DefaultAgents/`
|
||||||
|
Expected: all 6 `.md` files present.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||||
|
git commit -m "build(worker): ship DefaultAgents folder in build output"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Write DefaultAgentSeeder tests (failing)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
|
||||||
|
|
||||||
|
The service doesn't exist yet — these tests will fail to compile initially. That's fine; the next task implements it.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the test file**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Worker.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Services;
|
||||||
|
|
||||||
|
public sealed class DefaultAgentSeederTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _bundleDir;
|
||||||
|
private readonly string _targetDir;
|
||||||
|
|
||||||
|
public DefaultAgentSeederTests()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"claudedo_seeder_{Guid.NewGuid():N}");
|
||||||
|
_bundleDir = Path.Combine(root, "bundle");
|
||||||
|
_targetDir = Path.Combine(root, "target");
|
||||||
|
Directory.CreateDirectory(_bundleDir);
|
||||||
|
Directory.CreateDirectory(_targetDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(Path.GetDirectoryName(_bundleDir)!, true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteBundleAsync(string name, string content)
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(_bundleDir, name), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_CopiesAllFiles_WhenTargetEmpty()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "A");
|
||||||
|
await WriteBundleAsync("b.md", "B");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Copied);
|
||||||
|
Assert.Equal(0, result.Skipped);
|
||||||
|
Assert.True(File.Exists(Path.Combine(_targetDir, "a.md")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(_targetDir, "b.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_SkipsExistingFiles()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "bundled");
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "user-modified");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Copied);
|
||||||
|
Assert.Equal(1, result.Skipped);
|
||||||
|
var content = await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md"));
|
||||||
|
Assert.Equal("user-modified", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_MixedState_CopiesOnlyMissing()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "A");
|
||||||
|
await WriteBundleAsync("b.md", "B");
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "existing");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Copied);
|
||||||
|
Assert.Equal(1, result.Skipped);
|
||||||
|
Assert.Equal("existing", await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md")));
|
||||||
|
Assert.Equal("B", await File.ReadAllTextAsync(Path.Combine(_targetDir, "b.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_ReturnsZero_WhenBundleDirMissing()
|
||||||
|
{
|
||||||
|
var missingBundle = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
|
||||||
|
var seeder = new DefaultAgentSeeder(missingBundle, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Copied);
|
||||||
|
Assert.Equal(0, result.Skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_CreatesTargetDir_IfMissing()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "A");
|
||||||
|
var missingTarget = Path.Combine(_targetDir, "nested", "created");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, missingTarget);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Copied);
|
||||||
|
Assert.True(File.Exists(Path.Combine(missingTarget, "a.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_IgnoresNonMarkdownFiles()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "A");
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(_bundleDir, "readme.txt"), "not an agent");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Copied);
|
||||||
|
Assert.False(File.Exists(Path.Combine(_targetDir, "readme.txt")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to confirm they fail to compile**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DefaultAgentSeederTests"`
|
||||||
|
Expected: compile error — `The type or namespace name 'DefaultAgentSeeder' could not be found`.
|
||||||
|
|
||||||
|
This confirms the tests target the not-yet-written service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Implement DefaultAgentSeeder
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the service**
|
||||||
|
|
||||||
|
Create `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Services;
|
||||||
|
|
||||||
|
public sealed record SeedResult(int Copied, int Skipped);
|
||||||
|
|
||||||
|
public sealed class DefaultAgentSeeder
|
||||||
|
{
|
||||||
|
private readonly string _bundleDir;
|
||||||
|
private readonly string _targetDir;
|
||||||
|
private readonly ILogger<DefaultAgentSeeder>? _logger;
|
||||||
|
|
||||||
|
public DefaultAgentSeeder(string bundleDir, string targetDir, ILogger<DefaultAgentSeeder>? logger = null)
|
||||||
|
{
|
||||||
|
_bundleDir = bundleDir;
|
||||||
|
_targetDir = targetDir;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SeedResult> SeedMissingAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_bundleDir))
|
||||||
|
{
|
||||||
|
_logger?.LogWarning("DefaultAgents bundle dir not found: {Dir}", _bundleDir);
|
||||||
|
return new SeedResult(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_targetDir);
|
||||||
|
|
||||||
|
int copied = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
foreach (var src in Directory.EnumerateFiles(_bundleDir, "*.md"))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var fileName = Path.GetFileName(src);
|
||||||
|
var dst = Path.Combine(_targetDir, fileName);
|
||||||
|
|
||||||
|
if (File.Exists(dst))
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var input = File.OpenRead(src);
|
||||||
|
using var output = File.Create(dst);
|
||||||
|
await input.CopyToAsync(output, ct);
|
||||||
|
copied++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "Failed to copy default agent {File}", fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SeedResult(copied, skipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests and verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DefaultAgentSeederTests"`
|
||||||
|
Expected: 6 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs
|
||||||
|
git commit -m "feat(worker): add DefaultAgentSeeder for first-launch agent seeding"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Wire seeder into Worker startup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Program.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register seeder and run SeedMissingAsync at startup**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Program.cs`, replace the "Agent file management." block (currently lines 36–39):
|
||||||
|
|
||||||
|
Find:
|
||||||
|
```csharp
|
||||||
|
// Agent file management.
|
||||||
|
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||||
|
Directory.CreateDirectory(agentsDir);
|
||||||
|
builder.Services.AddSingleton(new AgentFileService(agentsDir));
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```csharp
|
||||||
|
// Agent file management.
|
||||||
|
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||||
|
Directory.CreateDirectory(agentsDir);
|
||||||
|
builder.Services.AddSingleton(new AgentFileService(agentsDir));
|
||||||
|
|
||||||
|
var defaultAgentsBundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
|
||||||
|
builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
|
||||||
|
defaultAgentsBundleDir,
|
||||||
|
agentsDir,
|
||||||
|
sp.GetService<Microsoft.Extensions.Logging.ILogger<DefaultAgentSeeder>>()));
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, after `var app = builder.Build();` and before `app.MapHub<WorkerHub>("/hub");`, add:
|
||||||
|
|
||||||
|
Find:
|
||||||
|
```csharp
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
ClaudeDoDbContext.MigrateAndConfigure(
|
||||||
|
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
|
||||||
|
}
|
||||||
|
|
||||||
|
app.MapHub<WorkerHub>("/hub");
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```csharp
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
ClaudeDoDbContext.MigrateAndConfigure(
|
||||||
|
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var seeder = app.Services.GetRequiredService<DefaultAgentSeeder>();
|
||||||
|
var seedResult = await seeder.SeedMissingAsync();
|
||||||
|
app.Logger.LogInformation(
|
||||||
|
"Default agents seeded: {Copied} copied, {Skipped} already present",
|
||||||
|
seedResult.Copied, seedResult.Skipped);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
app.Logger.LogWarning(ex, "Default agent seeding failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
app.MapHub<WorkerHub>("/hub");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build worker to verify compile**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Program.cs
|
||||||
|
git commit -m "feat(worker): seed default agents on startup"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Add RestoreDefaultAgents hub method (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
|
||||||
|
The existing `AgentSettingsHubTests` doesn't exercise `WorkerHub` directly (it tests the repository). We'll add a new test file that tests the seeder restore flow end-to-end without SignalR plumbing — constructing the seeder with temp dirs and asserting the `SeedResult` round-trip. This mirrors how the file is structured today and keeps tests simple.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a restore test**
|
||||||
|
|
||||||
|
Add to `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`. Inside the existing `AgentSettingsHubTests` class (before the closing brace), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task RestoreDefaultAgents_CopiesMissingBundledFiles()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"claudedo_hub_restore_{Guid.NewGuid():N}");
|
||||||
|
var bundleDir = Path.Combine(root, "bundle");
|
||||||
|
var targetDir = Path.Combine(root, "target");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(bundleDir);
|
||||||
|
Directory.CreateDirectory(targetDir);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(bundleDir, "code-reviewer.md"), "body");
|
||||||
|
|
||||||
|
var seeder = new ClaudeDo.Worker.Services.DefaultAgentSeeder(bundleDir, targetDir);
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Copied);
|
||||||
|
Assert.Equal(0, result.Skipped);
|
||||||
|
Assert.True(File.Exists(Path.Combine(targetDir, "code-reviewer.md")));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(root, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to confirm it passes (seeder already exists)**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentSettingsHubTests.RestoreDefaultAgents_CopiesMissingBundledFiles"`
|
||||||
|
Expected: 1 test passes.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add RestoreDefaultAgents to WorkerHub**
|
||||||
|
|
||||||
|
Edit `src/ClaudeDo.Worker/Hub/WorkerHub.cs`.
|
||||||
|
|
||||||
|
Add a new DTO record near the top with the other DTOs (after the `ListConfigDto` line on line 30):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record SeedResultDto(int Copied, int Skipped);
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the class field block. Find:
|
||||||
|
```csharp
|
||||||
|
private readonly QueueService _queue;
|
||||||
|
private readonly AgentFileService _agentService;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly WorktreeMaintenanceService _wtMaintenance;
|
||||||
|
private readonly TaskResetService _resetService;
|
||||||
|
private readonly TaskMergeService _mergeService;
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```csharp
|
||||||
|
private readonly QueueService _queue;
|
||||||
|
private readonly AgentFileService _agentService;
|
||||||
|
private readonly DefaultAgentSeeder _seeder;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly WorktreeMaintenanceService _wtMaintenance;
|
||||||
|
private readonly TaskResetService _resetService;
|
||||||
|
private readonly TaskMergeService _mergeService;
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the constructor. Find:
|
||||||
|
```csharp
|
||||||
|
public WorkerHub(
|
||||||
|
QueueService queue,
|
||||||
|
AgentFileService agentService,
|
||||||
|
HubBroadcaster broadcaster,
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
WorktreeMaintenanceService wtMaintenance,
|
||||||
|
TaskResetService resetService,
|
||||||
|
TaskMergeService mergeService)
|
||||||
|
{
|
||||||
|
_queue = queue;
|
||||||
|
_agentService = agentService;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_wtMaintenance = wtMaintenance;
|
||||||
|
_resetService = resetService;
|
||||||
|
_mergeService = mergeService;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```csharp
|
||||||
|
public WorkerHub(
|
||||||
|
QueueService queue,
|
||||||
|
AgentFileService agentService,
|
||||||
|
DefaultAgentSeeder seeder,
|
||||||
|
HubBroadcaster broadcaster,
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
WorktreeMaintenanceService wtMaintenance,
|
||||||
|
TaskResetService resetService,
|
||||||
|
TaskMergeService mergeService)
|
||||||
|
{
|
||||||
|
_queue = queue;
|
||||||
|
_agentService = agentService;
|
||||||
|
_seeder = seeder;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_wtMaintenance = wtMaintenance;
|
||||||
|
_resetService = resetService;
|
||||||
|
_mergeService = mergeService;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the hub method. After the existing `RefreshAgents` method (currently line 126):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<SeedResultDto> RestoreDefaultAgents()
|
||||||
|
{
|
||||||
|
var result = await _seeder.SeedMissingAsync();
|
||||||
|
return new SeedResultDto(result.Copied, result.Skipped);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build worker to verify compile**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all Worker tests to confirm no regressions**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs
|
||||||
|
git commit -m "feat(worker): expose RestoreDefaultAgents hub method"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Add RestoreDefaultAgentsAsync to WorkerClient
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the DTO record**
|
||||||
|
|
||||||
|
At the bottom of `src/ClaudeDo.Ui/Services/WorkerClient.cs`, alongside the other public record declarations (after `ListConfigDto`, currently line 350), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record SeedResultDto(int Copied, int Skipped);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the client method**
|
||||||
|
|
||||||
|
In the `WorkerClient` class, after the `RefreshAgentsAsync` method (currently line 232), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build UI to verify compile**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
|
||||||
|
git commit -m "feat(ui): add RestoreDefaultAgentsAsync to WorkerClient"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Add restore button to Settings modal
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the command to the viewmodel**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`, add a new command method. Place it after the existing `ConfirmResetAll` method (currently ending line 162), before the `OpenPath` method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RestoreDefaultAgents()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _worker.RestoreDefaultAgentsAsync();
|
||||||
|
if (result is null)
|
||||||
|
StatusMessage = "Worker offline.";
|
||||||
|
else if (result.Copied == 0 && result.Skipped == 0)
|
||||||
|
StatusMessage = "No default agents bundled.";
|
||||||
|
else if (result.Copied == 0)
|
||||||
|
StatusMessage = "All default agents already present.";
|
||||||
|
else
|
||||||
|
StatusMessage = $"Restored {result.Copied} default agent(s).";
|
||||||
|
|
||||||
|
await _worker.RefreshAgentsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Restore failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a button to the settings view**
|
||||||
|
|
||||||
|
Edit `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`. Add a new section after the `WORKTREES` StackPanel block (which ends with `</StackPanel>` around line 185) and before the `ABOUT` section (`<!-- ABOUT -->` around line 187).
|
||||||
|
|
||||||
|
Find:
|
||||||
|
```xml
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- ABOUT -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```xml
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- AGENTS -->
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
<TextBlock Classes="section-label" Text="AGENTS"/>
|
||||||
|
<Border Classes="section">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Restore bundled default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher). Existing files are not overwritten."
|
||||||
|
FontSize="11"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<Button Content="Restore default agents"
|
||||||
|
Command="{Binding RestoreDefaultAgentsCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- ABOUT -->
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build UI to verify compile**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual smoke test**
|
||||||
|
|
||||||
|
Start the worker and the UI. Open Settings (3-dots next to username). The AGENTS section should appear with a "Restore default agents" button.
|
||||||
|
|
||||||
|
1. With `~/.todo-app/agents/` empty (delete any existing `.md` files first, back them up if needed): click the button. Status should read "Restored N default agent(s)." The files should appear in the folder.
|
||||||
|
2. Click again. Status should read "All default agents already present."
|
||||||
|
3. Modify one of the restored files. Click restore. The modified file content should be preserved.
|
||||||
|
|
||||||
|
If any step fails, stop and fix before committing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
|
||||||
|
git commit -m "feat(ui): add Restore default agents button to Settings modal"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
At the end, run the full test suite and build all projects:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||||
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||||
|
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all builds succeed, all tests pass.
|
||||||
|
|
||||||
|
Additionally, start the Worker once with an empty `~/.todo-app/agents/` folder and confirm the log line:
|
||||||
|
> `Default agents seeded: 6 copied, 0 already present`
|
||||||
|
|
||||||
|
Then confirm `~/.todo-app/agents/` contains all 6 markdown files.
|
||||||
177
docs/superpowers/specs/2026-04-23-default-agents-design.md
Normal file
177
docs/superpowers/specs/2026-04-23-default-agents-design.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Default Agents — Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-23
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Ship ClaudeDo with a curated set of default agents so that users have useful agents available on first launch, without losing the file-based ownership model (user-editable, user-deletable). Provide a "Restore defaults" action to recover missing defaults on demand.
|
||||||
|
|
||||||
|
## Agents to Ship
|
||||||
|
|
||||||
|
Six markdown agents covering the common stages of task execution plus one general-purpose agent:
|
||||||
|
|
||||||
|
| File | Focus |
|
||||||
|
|---|---|
|
||||||
|
| `code-reviewer.md` | Review diff for bugs, logic errors, convention adherence. Flags only high-confidence issues. |
|
||||||
|
| `test-writer.md` | Generate unit/integration tests for changed code. Follows existing test patterns. |
|
||||||
|
| `debugger.md` | Systematic root-cause analysis — reproduce, isolate, hypothesize, verify. |
|
||||||
|
| `security-reviewer.md` | OWASP-style audit focused on auth, SQL injection, input handling, secret exposure. |
|
||||||
|
| `explorer.md` | Fast codebase navigation and answering "where/how" questions. Terse output. |
|
||||||
|
| `researcher.md` | General-purpose research, doc summarization, analysis, investigation. Non-code. |
|
||||||
|
|
||||||
|
Each file uses Claude Code's standard agent frontmatter:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: <agent name>
|
||||||
|
description: <one-line description>
|
||||||
|
---
|
||||||
|
|
||||||
|
<system prompt body>
|
||||||
|
```
|
||||||
|
|
||||||
|
Content target: ~20–40 lines per file. Style matches the existing Claude Code agent conventions.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
**Seed on first launch, restore on demand:**
|
||||||
|
|
||||||
|
1. Bundled agents live alongside the Worker binary at `<AppContext.BaseDirectory>/DefaultAgents/*.md`.
|
||||||
|
2. At Worker startup, for each bundled file: if `~/.todo-app/agents/<name>.md` does NOT exist, copy it in. If it exists, leave it alone — the user owns their copy.
|
||||||
|
3. A "Restore default agents" button in the settings modal re-runs the same check, restoring any that the user has deleted.
|
||||||
|
|
||||||
|
The seed path and the restore path are the same code — only the invocation differs (startup vs. hub call).
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### `DefaultAgents/*.md` (bundled content)
|
||||||
|
|
||||||
|
Location: `src/ClaudeDo.Worker/DefaultAgents/`
|
||||||
|
Packaging: `<Content Include="DefaultAgents\*.md" CopyToOutputDirectory="PreserveNewest" />` in `ClaudeDo.Worker.csproj`.
|
||||||
|
At runtime they land at `<AppContext.BaseDirectory>/DefaultAgents/*.md` next to the executable.
|
||||||
|
|
||||||
|
### `DefaultAgentSeeder` (new service)
|
||||||
|
|
||||||
|
Location: `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
|
||||||
|
|
||||||
|
```
|
||||||
|
public sealed class DefaultAgentSeeder
|
||||||
|
{
|
||||||
|
public DefaultAgentSeeder(string bundleDir, string targetDir);
|
||||||
|
public Task<SeedResult> SeedMissingAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SeedResult(int Copied, int Skipped);
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior of `SeedMissingAsync`:
|
||||||
|
- If `bundleDir` doesn't exist, log warning and return `(0, 0)`.
|
||||||
|
- Enumerate `*.md` in `bundleDir`.
|
||||||
|
- For each file, if the target path (`targetDir/<filename>`) is missing, copy it; else increment `Skipped`.
|
||||||
|
- Create `targetDir` if missing (consistent with existing `AgentFileService.WriteAsync`).
|
||||||
|
- Per-file exceptions are caught and logged; the seeder continues with the next file. The method itself does not throw for individual file failures.
|
||||||
|
|
||||||
|
### `Program.cs` wiring
|
||||||
|
|
||||||
|
After `AgentFileService` registration and before `app.Run()`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var bundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
|
||||||
|
var seeder = new DefaultAgentSeeder(bundleDir, agentsDir);
|
||||||
|
await seeder.SeedMissingAsync();
|
||||||
|
builder.Services.AddSingleton(seeder);
|
||||||
|
```
|
||||||
|
|
||||||
|
The seeder is also registered as a singleton so the hub can invoke it for the restore flow.
|
||||||
|
|
||||||
|
### `WorkerHub.RestoreDefaultAgents`
|
||||||
|
|
||||||
|
Location: add method to existing `src/ClaudeDo.Worker/Hub/WorkerHub.cs`.
|
||||||
|
|
||||||
|
Signature: `public async Task<SeedResult> RestoreDefaultAgents()`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Calls `DefaultAgentSeeder.SeedMissingAsync()`.
|
||||||
|
- Returns the `SeedResult` to the caller.
|
||||||
|
- No separate broadcast — the UI will call `GetAgents` after the restore returns, reusing the existing refresh path.
|
||||||
|
|
||||||
|
### UI — Settings Modal
|
||||||
|
|
||||||
|
Location: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` + `SettingsModalViewModel`.
|
||||||
|
|
||||||
|
Add a "Restore default agents" button to the modal. On click:
|
||||||
|
1. Disable the button, show a spinner label.
|
||||||
|
2. Call `WorkerClient.RestoreDefaultAgentsAsync()`.
|
||||||
|
3. Show a brief inline confirmation: `"Restored {Copied} agent(s)"` or `"All defaults already present"`.
|
||||||
|
4. Trigger the existing agent list refresh so the new files appear immediately in the rest of the UI.
|
||||||
|
|
||||||
|
### `WorkerClient` method
|
||||||
|
|
||||||
|
Add `Task<SeedResult> RestoreDefaultAgentsAsync(CancellationToken ct = default)` to `WorkerClient` — thin wrapper that invokes the hub method.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
**Startup:**
|
||||||
|
```
|
||||||
|
Worker starts → DefaultAgentSeeder.SeedMissingAsync()
|
||||||
|
→ copies missing files into ~/.todo-app/agents/
|
||||||
|
AgentFileService.ScanAsync() (on first GetAgents call) → sees the seeded files
|
||||||
|
```
|
||||||
|
|
||||||
|
**User restores:**
|
||||||
|
```
|
||||||
|
Settings modal button click
|
||||||
|
→ WorkerClient.RestoreDefaultAgentsAsync()
|
||||||
|
→ WorkerHub.RestoreDefaultAgents()
|
||||||
|
→ DefaultAgentSeeder.SeedMissingAsync()
|
||||||
|
→ returns SeedResult(copied, skipped)
|
||||||
|
UI shows confirmation, triggers GetAgents refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Failure | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Missing `DefaultAgents/` bundle dir | Log warning, return `(0, 0)`. Startup proceeds. |
|
||||||
|
| Individual file copy failure (disk, permissions) | Catch per-file, log, continue with the remaining files. |
|
||||||
|
| Corrupt bundled markdown (no valid frontmatter) | Copied anyway — the `AgentFileService` frontmatter parser already falls back to filename-as-name. |
|
||||||
|
| Startup seeder exception (unexpected) | Log as warning, do not crash the Worker. Agents can still be restored via the button. |
|
||||||
|
| Hub `RestoreDefaultAgents` exception | Propagate to client as SignalR error; UI shows a generic "Restore failed" message. |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Unit:** `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
|
||||||
|
|
||||||
|
- Seeds all files when target dir is empty.
|
||||||
|
- Skips files that already exist.
|
||||||
|
- Preserves existing user-modified files (file mtime / content unchanged).
|
||||||
|
- Returns accurate `SeedResult` counts.
|
||||||
|
- Handles missing bundle dir gracefully (returns `(0, 0)`, no throw).
|
||||||
|
- Creates target dir if it doesn't exist.
|
||||||
|
|
||||||
|
**Integration:** extend `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`
|
||||||
|
|
||||||
|
- `RestoreDefaultAgents` invokes the seeder and returns the count.
|
||||||
|
|
||||||
|
**No UI tests.** The project has no UI test harness; settings modal behavior is exercised manually.
|
||||||
|
|
||||||
|
## Build / Packaging
|
||||||
|
|
||||||
|
`src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` gains:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="DefaultAgents\*.md">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
No new NuGet dependencies.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Editing bundled agents in-place (user edits their copy under `~/.todo-app/agents/`; bundle is read-only by convention).
|
||||||
|
- Versioning / updating user copies when the bundled version changes. If a bundled agent is improved in a later release, the user's copy is not overwritten. A future release may add a "diff / reset to bundled" flow, but not now.
|
||||||
|
- Packaging as embedded resources. Content files copied to output are simpler, inspectable on disk, and consistent with the file-based agent model.
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 317 B After Width: | Height: | Size: 44 KiB |
@@ -88,7 +88,9 @@ sealed class Program
|
|||||||
sp,
|
sp,
|
||||||
sp.GetRequiredService<WorkerClient>()));
|
sp.GetRequiredService<WorkerClient>()));
|
||||||
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
||||||
new TasksIslandViewModel(sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>()));
|
new TasksIslandViewModel(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
|
sp.GetRequiredService<WorkerClient>()));
|
||||||
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
||||||
new DetailsIslandViewModel(
|
new DetailsIslandViewModel(
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public partial class App : Application
|
|||||||
context.Mode = state.Mode;
|
context.Mode = state.Mode;
|
||||||
context.InstalledVersion = state.Existing?.Version;
|
context.InstalledVersion = state.Existing?.Version;
|
||||||
context.LatestVersion = state.LatestVersion;
|
context.LatestVersion = state.LatestVersion;
|
||||||
|
context.LatestTagUnparseable = state.LatestTagUnparseable;
|
||||||
if (state.Existing is not null)
|
if (state.Existing is not null)
|
||||||
context.InstallDirectory = state.Existing.InstallDir;
|
context.InstallDirectory = state.Existing.InstallDir;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public sealed class InstallContext
|
|||||||
public string? InstallerVersion { get; set; } // from this installer's assembly
|
public string? InstallerVersion { get; set; } // from this installer's assembly
|
||||||
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
|
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
|
||||||
public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
|
public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
|
||||||
|
public bool LatestTagUnparseable { get; set; } // true if latest tag isn't a System.Version
|
||||||
|
|
||||||
// PathsPage
|
// PathsPage
|
||||||
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ public sealed record InstallManifest(
|
|||||||
string Version,
|
string Version,
|
||||||
string InstallDir,
|
string InstallDir,
|
||||||
string WorkerDir,
|
string WorkerDir,
|
||||||
DateTimeOffset InstalledAt);
|
DateTimeOffset InstalledAt,
|
||||||
|
string? DataDir = null);
|
||||||
|
|
||||||
public static class InstallManifestStore
|
public static class InstallManifestStore
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ public sealed record DetectedState(
|
|||||||
InstallerMode Mode,
|
InstallerMode Mode,
|
||||||
InstallManifest? Existing,
|
InstallManifest? Existing,
|
||||||
GiteaRelease? LatestRelease,
|
GiteaRelease? LatestRelease,
|
||||||
string? LatestVersion);
|
string? LatestVersion)
|
||||||
|
{
|
||||||
|
/// <summary>True when a release was returned but its tag isn't a parseable
|
||||||
|
/// System.Version (e.g. "0.2.0-beta") — so we couldn't decide if it's newer.</summary>
|
||||||
|
public bool LatestTagUnparseable { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class InstallModeDetector
|
public sealed class InstallModeDetector
|
||||||
{
|
{
|
||||||
@@ -26,23 +31,26 @@ 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');
|
||||||
if (IsNewer(latestVersion, manifest.Version))
|
var newer = IsNewer(latestVersion, manifest.Version, out var unparseable);
|
||||||
|
if (newer)
|
||||||
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
|
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
|
||||||
|
|
||||||
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion);
|
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion)
|
||||||
|
{
|
||||||
|
LatestTagUnparseable = unparseable,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
|
/// 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
|
/// 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.
|
/// treated as "not newer" — the user drops into Config mode with no update offered, but
|
||||||
/// This is deliberate: offering an update we can't compare is worse than silently skipping it.
|
/// <paramref name="unparseable"/> is set so the UI can surface a hint.
|
||||||
/// If the project starts shipping pre-release tags, revisit this.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool IsNewer(string latest, string current)
|
private static bool IsNewer(string latest, string current, out bool unparseable)
|
||||||
{
|
{
|
||||||
if (!Version.TryParse(latest, out var lv)) return false;
|
unparseable = !Version.TryParse(latest, out var lv) | !Version.TryParse(current, out var cv);
|
||||||
if (!Version.TryParse(current, out var cv)) return false;
|
if (unparseable) return false;
|
||||||
return lv > cv;
|
return lv > cv;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,10 +67,13 @@ public sealed class UninstallRunner
|
|||||||
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
|
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) Delete ~/.todo-app (config + DB + logs) — only if user opted in.
|
// 6) Delete data dir (config + DB + logs) — only if user opted in.
|
||||||
|
// Prefer the manifest-recorded DataDir so a customised DbPath is honoured;
|
||||||
|
// fall back to the default ~/.todo-app for older manifests.
|
||||||
if (removeAppData)
|
if (removeAppData)
|
||||||
{
|
{
|
||||||
var appData = Paths.AppDataRoot();
|
var manifest = InstallManifestStore.TryRead(_context.InstallDirectory);
|
||||||
|
var appData = manifest?.DataDir ?? Paths.AppDataRoot();
|
||||||
if (Directory.Exists(appData))
|
if (Directory.Exists(appData))
|
||||||
{
|
{
|
||||||
progress.Report($"Deleting {appData}...");
|
progress.Report($"Deleting {appData}...");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IO;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -15,6 +16,10 @@ public sealed class InitDatabaseStep : IInstallStep
|
|||||||
var expandedPath = Paths.Expand(ctx.DbPath);
|
var expandedPath = Paths.Expand(ctx.DbPath);
|
||||||
progress.Report($"Initializing database at {expandedPath}");
|
progress.Report($"Initializing database at {expandedPath}");
|
||||||
|
|
||||||
|
var parent = Path.GetDirectoryName(expandedPath);
|
||||||
|
if (!string.IsNullOrEmpty(parent))
|
||||||
|
Directory.CreateDirectory(parent);
|
||||||
|
|
||||||
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
.UseSqlite($"Data Source={expandedPath}")
|
.UseSqlite($"Data Source={expandedPath}")
|
||||||
.Options;
|
.Options;
|
||||||
|
|||||||
@@ -43,13 +43,21 @@ public sealed class RegisterServiceStep : IInstallStep
|
|||||||
|
|
||||||
// Create service
|
// Create service
|
||||||
var startType = ctx.AutoStart ? "auto" : "demand";
|
var startType = ctx.AutoStart ? "auto" : "demand";
|
||||||
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
|
|
||||||
|
|
||||||
if (ctx.ServiceAccount == "CurrentUser")
|
if (ctx.ServiceAccount == "CurrentUser")
|
||||||
return StepResult.Fail(
|
return StepResult.Fail(
|
||||||
"Service cannot run as Current User without a password. " +
|
"Service cannot run as Current User without a password. " +
|
||||||
"Select 'Local System' or extend ServicePage to capture a password.");
|
"Select 'Local System' or extend ServicePage to capture a password.");
|
||||||
|
|
||||||
|
var objArg = ctx.ServiceAccount switch
|
||||||
|
{
|
||||||
|
"LocalSystem" => " obj= LocalSystem",
|
||||||
|
"NetworkService" => " obj= \"NT AUTHORITY\\NetworkService\"",
|
||||||
|
"LocalService" => " obj= \"NT AUTHORITY\\LocalService\"",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}{objArg}";
|
||||||
|
|
||||||
progress.Report("Creating service...");
|
progress.Report("Creating service...");
|
||||||
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
|
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
|
||||||
if (exitCode == 1072)
|
if (exitCode == 1072)
|
||||||
|
|||||||
@@ -13,15 +13,26 @@ public sealed class StartServiceStep : IInstallStep
|
|||||||
progress.Report($"Starting {ServiceName}...");
|
progress.Report($"Starting {ServiceName}...");
|
||||||
|
|
||||||
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
|
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
|
||||||
if (exit == 0) return StepResult.Ok();
|
// 1056 = ERROR_SERVICE_ALREADY_RUNNING — fine, fall through to the readiness poll.
|
||||||
|
if (exit != 0 && exit != 1056)
|
||||||
|
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
|
||||||
|
|
||||||
// Exit 1056 = ERROR_SERVICE_ALREADY_RUNNING — that's fine too.
|
|
||||||
if (exit == 1056)
|
if (exit == 1056)
|
||||||
{
|
|
||||||
progress.Report("Service was already running.");
|
progress.Report("Service was already running.");
|
||||||
return StepResult.Ok();
|
|
||||||
|
// sc.exe start returns as soon as SCM accepts the command. Poll until the
|
||||||
|
// service actually reports RUNNING so downstream steps and SignalR clients
|
||||||
|
// don't race the worker's startup.
|
||||||
|
progress.Report("Waiting for service to reach RUNNING state...");
|
||||||
|
for (var i = 0; i < 30; i++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var (q, output) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
|
||||||
|
if (q == 0 && output.Contains("RUNNING", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return StepResult.Ok();
|
||||||
|
await Task.Delay(1000, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
|
return StepResult.Fail("Service did not reach RUNNING state within 30 seconds.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Steps;
|
namespace ClaudeDo.Installer.Steps;
|
||||||
@@ -14,11 +15,14 @@ public sealed class WriteInstallManifestStep : IInstallStep
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var dataDir = Path.GetDirectoryName(Paths.Expand(ctx.DbPath));
|
||||||
|
|
||||||
var manifest = new InstallManifest(
|
var manifest = new InstallManifest(
|
||||||
Version: ctx.InstalledVersion,
|
Version: ctx.InstalledVersion,
|
||||||
InstallDir: ctx.InstallDirectory,
|
InstallDir: ctx.InstallDirectory,
|
||||||
WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
|
WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
|
||||||
InstalledAt: DateTimeOffset.UtcNow);
|
InstalledAt: DateTimeOffset.UtcNow,
|
||||||
|
DataDir: dataDir);
|
||||||
|
|
||||||
InstallManifestStore.Write(ctx.InstallDirectory, manifest);
|
InstallManifestStore.Write(ctx.InstallDirectory, manifest);
|
||||||
progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");
|
progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_uninstallRunner = uninstallRunner;
|
_uninstallRunner = uninstallRunner;
|
||||||
_selectedPage = Pages.FirstOrDefault();
|
_selectedPage = Pages.FirstOrDefault();
|
||||||
|
|
||||||
VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
|
var label = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
|
||||||
|
if (!string.IsNullOrEmpty(context.LatestVersion))
|
||||||
|
label += $" Latest: {context.LatestVersion}";
|
||||||
|
if (context.LatestTagUnparseable)
|
||||||
|
label += " (pre-release tag — auto-update disabled)";
|
||||||
|
VersionLabel = label;
|
||||||
|
|
||||||
_ = LoadAllAsync();
|
_ = LoadAllAsync();
|
||||||
}
|
}
|
||||||
@@ -98,8 +103,39 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
};
|
};
|
||||||
uiCfg.Save();
|
uiCfg.Save();
|
||||||
|
|
||||||
StatusMessage = "Settings saved.";
|
|
||||||
IsStatusError = false;
|
IsStatusError = false;
|
||||||
|
StatusMessage = "Settings saved.";
|
||||||
|
|
||||||
|
// Worker reads its config at process start, so changes only take effect after a restart.
|
||||||
|
var restart = MessageBox.Show(
|
||||||
|
"Restart the worker service now so the new settings take effect?",
|
||||||
|
"Restart Worker",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Question);
|
||||||
|
|
||||||
|
if (restart != MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
StatusMessage = "Settings saved. Restart the worker service manually to apply.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress = new Progress<string>(msg => StatusMessage = msg);
|
||||||
|
var stop = await _stopService.ExecuteAsync(_context, progress, CancellationToken.None);
|
||||||
|
if (!stop.Success)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Saved, but worker stop failed: {stop.ErrorMessage}";
|
||||||
|
IsStatusError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var start = await _startService.ExecuteAsync(_context, progress, CancellationToken.None);
|
||||||
|
if (!start.Success)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Saved, but worker start failed: {start.ErrorMessage}";
|
||||||
|
IsStatusError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusMessage = "Settings saved. Worker restarted.";
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<AvaloniaResource Include="Assets/Fonts/*.ttf" />
|
<AvaloniaResource Include="Assets/Fonts/*.ttf" />
|
||||||
<AvaloniaResource Include="Assets/Fonts/OFL-InterTight.txt" />
|
<AvaloniaResource Include="Assets/Fonts/OFL-InterTight.txt" />
|
||||||
<AvaloniaResource Include="Assets/Fonts/OFL-JetBrainsMono.txt" />
|
<AvaloniaResource Include="Assets/Fonts/OFL-JetBrainsMono.txt" />
|
||||||
|
<AvaloniaResource Include="..\ClaudeDo.App\Assets\ClaudeTask.ico" Link="Assets\ClaudeTask.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -95,6 +95,8 @@
|
|||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="0" />
|
<Setter Property="CornerRadius" Value="0" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.title-ctrl:pointerover /template/ ContentPresenter">
|
<Style Selector="Button.title-ctrl:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
@@ -221,6 +223,8 @@
|
|||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="6" />
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter">
|
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
@@ -539,6 +543,20 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- Count badge — larger, high contrast, brighter when the row is active -->
|
||||||
|
<Style Selector="TextBlock.list-count">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="Margin" Value="8,0,4,0" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.list-item.active TextBlock.list-count">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- LIST SECTION HEADER -->
|
<!-- LIST SECTION HEADER -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
@@ -582,16 +600,21 @@
|
|||||||
<!-- KBD CHIP -->
|
<!-- KBD CHIP -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<Style Selector="Border.kbd">
|
<Style Selector="Border.kbd">
|
||||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="4" />
|
<Setter Property="CornerRadius" Value="4" />
|
||||||
<Setter Property="Padding" Value="6,2" />
|
<Setter Property="Padding" Value="8,3" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.kbd > TextBlock">
|
<Style Selector="Border.kbd > TextBlock">
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="10" />
|
<Setter Property="FontSize" Value="10" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="TextAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
|
|||||||
@@ -231,6 +231,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
await _hub.InvokeAsync("RefreshAgents");
|
await _hub.InvokeAsync("RefreshAgents");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SeedActiveTasksAsync()
|
private async Task SeedActiveTasksAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -348,3 +360,4 @@ public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, s
|
|||||||
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath);
|
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath);
|
||||||
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
|
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
|
||||||
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
|
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
|
||||||
|
public sealed record SeedResultDto(int Copied, int Skipped);
|
||||||
|
|||||||
@@ -378,6 +378,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
// Subscribe only after DB load confirms the task exists
|
// Subscribe only after DB load confirms the task exists
|
||||||
_subscribedTaskId = row.Id;
|
_subscribedTaskId = row.Id;
|
||||||
|
|
||||||
|
// Replay the latest run's persisted log so output is visible across app restarts.
|
||||||
|
await ReplayLogFileAsync(entity.LogPath, ct);
|
||||||
|
|
||||||
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
|
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
foreach (var s in subs)
|
foreach (var s in subs)
|
||||||
@@ -386,6 +389,59 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task ReplayLogFileAsync(string? logPath, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(logPath)) return;
|
||||||
|
var expanded = ExpandUserPath(logPath);
|
||||||
|
if (!System.IO.File.Exists(expanded)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const int maxLines = 2000;
|
||||||
|
string[] all;
|
||||||
|
await using (var fs = new System.IO.FileStream(
|
||||||
|
expanded,
|
||||||
|
System.IO.FileMode.Open,
|
||||||
|
System.IO.FileAccess.Read,
|
||||||
|
System.IO.FileShare.ReadWrite | System.IO.FileShare.Delete))
|
||||||
|
using (var reader = new System.IO.StreamReader(fs))
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
while (await reader.ReadLineAsync(ct) is { } line)
|
||||||
|
list.Add(line);
|
||||||
|
all = list.ToArray();
|
||||||
|
}
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var start = Math.Max(0, all.Length - maxLines);
|
||||||
|
for (int i = start; i < all.Length; i++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (_subscribedTaskId is null) return;
|
||||||
|
// Worker writes raw Claude CLI stdout to disk (no prefix) but broadcasts
|
||||||
|
// it with a "[stdout] " prefix. Match the live-stream format so the same
|
||||||
|
// stream-json parser handles both.
|
||||||
|
var line = all[i];
|
||||||
|
var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line;
|
||||||
|
OnTaskMessage(_subscribedTaskId, normalized);
|
||||||
|
}
|
||||||
|
FlushClaudeBuffer();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch { /* best-effort replay */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExpandUserPath(string path)
|
||||||
|
{
|
||||||
|
if (path.StartsWith("~/", StringComparison.Ordinal) || path.StartsWith("~\\", StringComparison.Ordinal))
|
||||||
|
return System.IO.Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
path[2..]);
|
||||||
|
if (path == "~")
|
||||||
|
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
|
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using System.Collections.ObjectModel;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
@@ -68,7 +70,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
: Environment.UserName.ToUpperInvariant();
|
: Environment.UserName.ToUpperInvariant();
|
||||||
|
|
||||||
if (_worker is not null)
|
if (_worker is not null)
|
||||||
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id);
|
{
|
||||||
|
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id);
|
||||||
|
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
|
||||||
|
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
||||||
|
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAsync(CancellationToken ct = default)
|
public async Task LoadAsync(CancellationToken ct = default)
|
||||||
@@ -82,6 +89,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
new ListNavItemViewModel { Id = "smart:my-day", Name = "My Day", Kind = ListKind.Smart, IconKey = "Sun" },
|
new ListNavItemViewModel { Id = "smart:my-day", Name = "My Day", Kind = ListKind.Smart, IconKey = "Sun" },
|
||||||
new ListNavItemViewModel { Id = "smart:important", Name = "Important", Kind = ListKind.Smart, IconKey = "Star" },
|
new ListNavItemViewModel { Id = "smart:important", Name = "Important", Kind = ListKind.Smart, IconKey = "Star" },
|
||||||
new ListNavItemViewModel { Id = "smart:planned", Name = "Planned", Kind = ListKind.Smart, IconKey = "Calendar" },
|
new ListNavItemViewModel { Id = "smart:planned", Name = "Planned", Kind = ListKind.Smart, IconKey = "Calendar" },
|
||||||
|
new ListNavItemViewModel { Id = "virtual:queued", Name = "Queue", Kind = ListKind.Virtual, IconKey = "Inbox" },
|
||||||
new ListNavItemViewModel { Id = "virtual:running", Name = "Running", Kind = ListKind.Virtual, IconKey = "Activity" },
|
new ListNavItemViewModel { Id = "virtual:running", Name = "Running", Kind = ListKind.Virtual, IconKey = "Activity" },
|
||||||
new ListNavItemViewModel { Id = "virtual:review", Name = "Review", Kind = ListKind.Virtual, IconKey = "Eye" },
|
new ListNavItemViewModel { Id = "virtual:review", Name = "Review", Kind = ListKind.Virtual, IconKey = "Eye" },
|
||||||
};
|
};
|
||||||
@@ -116,8 +124,46 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
public async Task RefreshCountsAsync(CancellationToken ct = default)
|
public async Task RefreshCountsAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
foreach (var i in Items) i.Count = 0;
|
try
|
||||||
await Task.CompletedTask;
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
|
||||||
|
// Snapshot the open (non-Done) tasks once; small enough collection for client-side grouping.
|
||||||
|
var open = await ctx.Tasks.AsNoTracking()
|
||||||
|
.Where(t => t.Status != TaskStatus.Done)
|
||||||
|
.Select(t => new { t.ListId, t.Status, t.IsMyDay, t.IsStarred, Scheduled = t.ScheduledFor })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var running = open.Count(t => t.Status == TaskStatus.Running);
|
||||||
|
var queued = open.Count(t => t.Status == TaskStatus.Queued);
|
||||||
|
var review = await ctx.Tasks.AsNoTracking()
|
||||||
|
.Where(t => t.Status == TaskStatus.Done && t.Worktree != null && t.Worktree.State == WorktreeState.Active)
|
||||||
|
.CountAsync(ct);
|
||||||
|
|
||||||
|
foreach (var item in SmartLists)
|
||||||
|
{
|
||||||
|
item.Count = item.Id switch
|
||||||
|
{
|
||||||
|
"smart:my-day" => open.Count(t => t.IsMyDay),
|
||||||
|
"smart:important" => open.Count(t => t.IsStarred),
|
||||||
|
"smart:planned" => open.Count(t => t.Scheduled != null),
|
||||||
|
"virtual:queued" => queued,
|
||||||
|
"virtual:running" => running,
|
||||||
|
"virtual:review" => review,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in UserLists)
|
||||||
|
{
|
||||||
|
var listId = item.Id.StartsWith("user:", StringComparison.Ordinal)
|
||||||
|
? item.Id["user:".Length..]
|
||||||
|
: item.Id;
|
||||||
|
item.Count = open.Count(t => t.ListId == listId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch { /* best-effort refresh */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public bool HasSteps => StepsCount > 0;
|
public bool HasSteps => StepsCount > 0;
|
||||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||||
public bool IsRunning => Status == TaskStatus.Running;
|
public bool IsRunning => Status == TaskStatus.Running;
|
||||||
|
public bool IsQueued => Status == TaskStatus.Queued;
|
||||||
|
public bool HasSchedule => ScheduledFor.HasValue;
|
||||||
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||||
|
|
||||||
public string DiffAdditionsText => $"+{DiffAdditions}";
|
public string DiffAdditionsText => $"+{DiffAdditions}";
|
||||||
@@ -56,13 +58,18 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(StatusChipClass));
|
OnPropertyChanged(nameof(StatusChipClass));
|
||||||
OnPropertyChanged(nameof(IsRunning));
|
OnPropertyChanged(nameof(IsRunning));
|
||||||
|
OnPropertyChanged(nameof(IsQueued));
|
||||||
OnPropertyChanged(nameof(HasLiveTail));
|
OnPropertyChanged(nameof(HasLiveTail));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||||
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
|
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
|
||||||
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
|
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
|
||||||
partial void OnScheduledForChanged(DateTime? value) => OnPropertyChanged(nameof(IsOverdue));
|
partial void OnScheduledForChanged(DateTime? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsOverdue));
|
||||||
|
OnPropertyChanged(nameof(HasSchedule));
|
||||||
|
}
|
||||||
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
|
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
|
||||||
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
|
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
@@ -12,11 +13,13 @@ 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 ListNavItemViewModel? _currentList;
|
private ListNavItemViewModel? _currentList;
|
||||||
private CancellationTokenSource? _loadCts;
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
public event EventHandler? SelectionChanged;
|
public event EventHandler? SelectionChanged;
|
||||||
public event EventHandler? FocusAddTaskRequested;
|
public event EventHandler? FocusAddTaskRequested;
|
||||||
|
public event EventHandler? TasksChanged;
|
||||||
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
|
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
public ObservableCollection<TaskRowViewModel> Items { get; } = new();
|
public ObservableCollection<TaskRowViewModel> Items { get; } = new();
|
||||||
@@ -38,9 +41,10 @@ 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)
|
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient? worker = null)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
|
_worker = worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadForList(ListNavItemViewModel? list)
|
public void LoadForList(ListNavItemViewModel? list)
|
||||||
@@ -85,6 +89,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
|
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
|
||||||
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
|
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
|
||||||
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
||||||
|
ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t => t.Status == TaskStatus.Queued),
|
||||||
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running),
|
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running),
|
||||||
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active),
|
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active),
|
||||||
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
|
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
|
||||||
@@ -170,6 +175,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
Regroup();
|
Regroup();
|
||||||
NewTaskTitle = "";
|
NewTaskTitle = "";
|
||||||
UpdateSubtitle();
|
UpdateSubtitle();
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CanReorder => _currentList?.Kind == ListKind.User;
|
public bool CanReorder => _currentList?.Kind == ListKind.User;
|
||||||
@@ -199,15 +205,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
if (source.IsRunning || target.IsRunning) return;
|
if (source.IsRunning || target.IsRunning) return;
|
||||||
if (ReferenceEquals(source, target)) return;
|
if (ReferenceEquals(source, target)) return;
|
||||||
|
|
||||||
var srcIdx = Items.IndexOf(source);
|
// Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
|
||||||
var tgtIdx = Items.IndexOf(target);
|
MoveWithinCollection(Items, source, target, placeBelow);
|
||||||
if (srcIdx < 0 || tgtIdx < 0) return;
|
|
||||||
|
|
||||||
Items.RemoveAt(srcIdx);
|
// Apply the same move in whichever section the row lives in.
|
||||||
var newTgtIdx = Items.IndexOf(target);
|
// Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
|
||||||
var insertIdx = placeBelow ? newTgtIdx + 1 : newTgtIdx;
|
// that's determined by Done flag and ScheduledFor date, not drag-drop.
|
||||||
if (insertIdx < 0 || insertIdx > Items.Count) insertIdx = Items.Count;
|
var sourceSection = SectionFor(source);
|
||||||
Items.Insert(insertIdx, source);
|
var targetSection = SectionFor(target);
|
||||||
|
if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection))
|
||||||
|
MoveWithinCollection(sourceSection, source, target, placeBelow);
|
||||||
|
|
||||||
var listId = _currentList.Id["user:".Length..];
|
var listId = _currentList.Id["user:".Length..];
|
||||||
var orderedIds = Items.Select(i => i.Id).ToList();
|
var orderedIds = Items.Select(i => i.Id).ToList();
|
||||||
@@ -223,8 +230,33 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
if (e is not null) e.SortOrder = i;
|
if (e is not null) e.SortOrder = i;
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
Regroup();
|
private static void MoveWithinCollection(
|
||||||
|
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> coll,
|
||||||
|
TaskRowViewModel source,
|
||||||
|
TaskRowViewModel target,
|
||||||
|
bool placeBelow)
|
||||||
|
{
|
||||||
|
var srcIdx = coll.IndexOf(source);
|
||||||
|
var tgtIdx = coll.IndexOf(target);
|
||||||
|
if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return;
|
||||||
|
|
||||||
|
var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx;
|
||||||
|
if (srcIdx < finalIdx) finalIdx--;
|
||||||
|
if (finalIdx < 0) finalIdx = 0;
|
||||||
|
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
|
||||||
|
if (finalIdx == srcIdx) return;
|
||||||
|
|
||||||
|
coll.Move(srcIdx, finalIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
|
||||||
|
{
|
||||||
|
if (OverdueItems.Contains(row)) return OverdueItems;
|
||||||
|
if (OpenItems.Contains(row)) return OpenItems;
|
||||||
|
if (CompletedItems.Contains(row)) return CompletedItems;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -241,6 +273,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
Regroup();
|
Regroup();
|
||||||
UpdateSubtitle();
|
UpdateSubtitle();
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -254,8 +287,61 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
entity.IsStarred = row.IsStarred;
|
entity.IsStarred = row.IsStarred;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || row.IsRunning) return;
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||||
|
if (entity is null) return;
|
||||||
|
entity.Status = TaskStatus.Queued;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
row.Status = TaskStatus.Queued;
|
||||||
|
if (_worker is not null)
|
||||||
|
{
|
||||||
|
try { await _worker.WakeQueueAsync(); } catch { }
|
||||||
|
}
|
||||||
|
Regroup();
|
||||||
|
UpdateSubtitle();
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||||
|
if (entity is null) return;
|
||||||
|
entity.Status = TaskStatus.Manual;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
row.Status = TaskStatus.Manual;
|
||||||
|
Regroup();
|
||||||
|
UpdateSubtitle();
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||||
|
if (entity is null) return;
|
||||||
|
entity.ScheduledFor = when;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
row.ScheduledFor = when;
|
||||||
|
Regroup();
|
||||||
|
UpdateSubtitle();
|
||||||
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task ClearScheduleAsync(TaskRowViewModel? row) =>
|
||||||
|
row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Select(TaskRowViewModel row) => SelectedTask = row;
|
private void Select(TaskRowViewModel row) => SelectedTask = row;
|
||||||
|
|
||||||
|
|||||||
@@ -52,10 +52,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||||
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();
|
||||||
Details.CloseDetail = () => Tasks.SelectedTask = null;
|
Details.CloseDetail = () => Tasks.SelectedTask = null;
|
||||||
Details.DeleteFromList = _ =>
|
Details.DeleteFromList = row =>
|
||||||
{
|
{
|
||||||
Tasks.LoadForList(Lists.SelectedList);
|
Tasks.LoadForList(Lists.SelectedList);
|
||||||
|
_ = Lists.RefreshCountsAsync();
|
||||||
return System.Threading.Tasks.Task.CompletedTask;
|
return System.Threading.Tasks.Task.CompletedTask;
|
||||||
};
|
};
|
||||||
Worker.PropertyChanged += (_, e) =>
|
Worker.PropertyChanged += (_, e) =>
|
||||||
|
|||||||
@@ -161,6 +161,32 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
finally { IsBusy = false; }
|
finally { IsBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RestoreDefaultAgents()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _worker.RestoreDefaultAgentsAsync();
|
||||||
|
if (result is null)
|
||||||
|
StatusMessage = "Worker offline.";
|
||||||
|
else if (result.Copied == 0 && result.Skipped == 0)
|
||||||
|
StatusMessage = "No default agents bundled.";
|
||||||
|
else if (result.Copied == 0)
|
||||||
|
StatusMessage = "All default agents already present.";
|
||||||
|
else
|
||||||
|
StatusMessage = $"Restored {result.Copied} default agent(s).";
|
||||||
|
|
||||||
|
await _worker.RefreshAgentsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Restore failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenPath(string? path)
|
private void OpenPath(string? path)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
<!-- More button -->
|
<!-- More button -->
|
||||||
<Button Grid.Column="2" Classes="icon-btn" VerticalAlignment="Center"
|
<Button Grid.Column="2" Classes="icon-btn" VerticalAlignment="Center"
|
||||||
Command="{Binding OpenListSettingsCommand}"
|
Command="{Binding OpenSettingsCommand}"
|
||||||
ToolTip.Tip="Settings">
|
ToolTip.Tip="Settings">
|
||||||
<PathIcon Data="{StaticResource Icon.MoreHorizontal}"
|
<PathIcon Data="{StaticResource Icon.MoreHorizontal}"
|
||||||
Width="14" Height="14"
|
Width="14" Height="14"
|
||||||
@@ -112,11 +112,8 @@
|
|||||||
VerticalAlignment="Center" Margin="8,0"
|
VerticalAlignment="Center" Margin="8,0"
|
||||||
Foreground="{DynamicResource TextDimBrush}" FontSize="13"/>
|
Foreground="{DynamicResource TextDimBrush}" FontSize="13"/>
|
||||||
<!-- Count -->
|
<!-- Count -->
|
||||||
<TextBlock Grid.Column="2"
|
<TextBlock Grid.Column="2" Classes="list-count"
|
||||||
Text="{Binding Count}"
|
Text="{Binding Count}"/>
|
||||||
FontFamily="{DynamicResource MonoFamily}" FontSize="10"
|
|
||||||
Foreground="{DynamicResource TextFaintBrush}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -160,20 +157,13 @@
|
|||||||
VerticalAlignment="Center" Margin="8,0"
|
VerticalAlignment="Center" Margin="8,0"
|
||||||
Foreground="{DynamicResource TextDimBrush}" FontSize="13"/>
|
Foreground="{DynamicResource TextDimBrush}" FontSize="13"/>
|
||||||
<!-- Count -->
|
<!-- Count -->
|
||||||
<TextBlock Grid.Column="2"
|
<TextBlock Grid.Column="2" Classes="list-count"
|
||||||
Text="{Binding Count}"
|
Text="{Binding Count}"/>
|
||||||
FontFamily="{DynamicResource MonoFamily}" FontSize="10"
|
|
||||||
Foreground="{DynamicResource TextFaintBrush}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<!-- Gear button -->
|
<!-- Gear button -->
|
||||||
<Button Grid.Column="3"
|
<Button Grid.Column="3" Classes="icon-btn"
|
||||||
Content="⚙"
|
Content="⚙"
|
||||||
|
FontSize="12"
|
||||||
ToolTip.Tip="Settings..."
|
ToolTip.Tip="Settings..."
|
||||||
Background="Transparent"
|
|
||||||
BorderThickness="0"
|
|
||||||
Padding="4,0"
|
|
||||||
FontSize="11"
|
|
||||||
Foreground="{DynamicResource TextFaintBrush}"
|
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
|
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
|
||||||
CommandParameter="{Binding}"/>
|
CommandParameter="{Binding}"/>
|
||||||
|
|||||||
@@ -50,23 +50,20 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- ── Log output ── -->
|
<!-- ── Log output ── -->
|
||||||
<ScrollViewer Name="LogScroll" VerticalScrollBarVisibility="Auto"
|
<ScrollViewer Name="LogScroll"
|
||||||
|
VerticalScrollBarVisibility="Visible"
|
||||||
|
AllowAutoHide="False"
|
||||||
Padding="10,8,10,12">
|
Padding="10,8,10,12">
|
||||||
<ItemsControl ItemsSource="{Binding Log}">
|
<ItemsControl ItemsSource="{Binding Log}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate DataType="vm:LogLineViewModel">
|
<DataTemplate DataType="vm:LogLineViewModel">
|
||||||
<Grid ColumnDefinitions="60,46,*" Margin="0,1">
|
<Grid ColumnDefinitions="60,*" Margin="0,1">
|
||||||
<!-- Timestamp -->
|
<!-- Timestamp -->
|
||||||
<TextBlock Grid.Column="0"
|
<TextBlock Grid.Column="0"
|
||||||
Classes="log-ts"
|
Classes="log-ts"
|
||||||
Text="{Binding TimestampFormatted}"/>
|
Text="{Binding TimestampFormatted}"/>
|
||||||
<!-- Kind marker -->
|
|
||||||
<TextBlock Grid.Column="1"
|
|
||||||
Classes="log-kind"
|
|
||||||
Tag="{Binding ClassName}"
|
|
||||||
Text="{Binding KindMarker}"/>
|
|
||||||
<!-- Message text — selectable so the user can copy raw output -->
|
<!-- Message text — selectable so the user can copy raw output -->
|
||||||
<SelectableTextBlock Grid.Column="2"
|
<SelectableTextBlock Grid.Column="1"
|
||||||
Text="{Binding Text}" Tag="{Binding ClassName}"
|
Text="{Binding Text}" Tag="{Binding ClassName}"
|
||||||
FontFamily="{DynamicResource MonoFont}" FontSize="11"
|
FontFamily="{DynamicResource MonoFont}" FontSize="11"
|
||||||
Foreground="{DynamicResource TextDimBrush}"
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
|||||||
@@ -19,11 +19,22 @@
|
|||||||
Margin="0"
|
Margin="0"
|
||||||
Classes.selected="{Binding IsSelected}"
|
Classes.selected="{Binding IsSelected}"
|
||||||
Classes.done="{Binding Done}">
|
Classes.done="{Binding Done}">
|
||||||
<Grid ColumnDefinitions="4,32,*,32" Margin="6,8,10,8">
|
<Border.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
<!-- Left accent bar (visible when selected) -->
|
<MenuItem Header="Send to queue"
|
||||||
<Border Grid.Column="0" Classes="task-row-accent"
|
IsVisible="{Binding !IsQueued}"
|
||||||
IsVisible="{Binding IsSelected}"/>
|
Click="OnSendToQueueClick"/>
|
||||||
|
<MenuItem Header="Remove from queue"
|
||||||
|
IsVisible="{Binding IsQueued}"
|
||||||
|
Click="OnRemoveFromQueueClick"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
|
||||||
|
<MenuItem Header="Clear schedule"
|
||||||
|
IsVisible="{Binding HasSchedule}"
|
||||||
|
Click="OnClearScheduleClick"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</Border.ContextMenu>
|
||||||
|
<Grid ColumnDefinitions="0,32,*,32" Margin="6,8,10,8">
|
||||||
|
|
||||||
<!-- Done toggle -->
|
<!-- Done toggle -->
|
||||||
<Button Grid.Column="1" Classes="flat" VerticalAlignment="Top"
|
<Button Grid.Column="1" Classes="flat" VerticalAlignment="Top"
|
||||||
@@ -53,6 +64,15 @@
|
|||||||
<TextBlock Text="{Binding Status}"/>
|
<TextBlock Text="{Binding Status}"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Dequeue button (only when Queued) -->
|
||||||
|
<Button Classes="icon-btn dequeue-btn"
|
||||||
|
IsVisible="{Binding IsQueued}"
|
||||||
|
ToolTip.Tip="Remove from queue"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<!-- List chip with dot -->
|
<!-- List chip with dot -->
|
||||||
<Border Classes="chip chip-list">
|
<Border Classes="chip chip-list">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
||||||
@@ -129,5 +149,45 @@
|
|||||||
<Border Height="2" VerticalAlignment="Center" Margin="4,0"
|
<Border Height="2" VerticalAlignment="Center" Margin="4,0"
|
||||||
Background="{DynamicResource MossBrush}" CornerRadius="1"/>
|
Background="{DynamicResource MossBrush}" CornerRadius="1"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Hidden schedule anchor (its Flyout is shown from the context menu) -->
|
||||||
|
<Button Grid.Row="1" x:Name="ScheduleAnchor"
|
||||||
|
Width="1" Height="1" Opacity="0"
|
||||||
|
HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||||
|
IsHitTestVisible="False" Focusable="False">
|
||||||
|
<Button.Flyout>
|
||||||
|
<Flyout Placement="Bottom" ShowMode="Standard">
|
||||||
|
<Border Background="{DynamicResource Surface2Brush}"
|
||||||
|
BorderBrush="{DynamicResource BorderBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="10"
|
||||||
|
Padding="16" Width="300">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Text="Schedule task"
|
||||||
|
FontWeight="SemiBold" FontSize="13"
|
||||||
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="DATE" FontSize="10" Opacity="0.6"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<DatePicker x:Name="ScheduleDate" HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="TIME" FontSize="10" Opacity="0.6"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TimePicker x:Name="ScheduleTime" ClockIdentifier="24HourClock"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8"
|
||||||
|
HorizontalAlignment="Right" Margin="0,4,0,0">
|
||||||
|
<Button Content="Cancel" Click="OnScheduleCancelClick" MinWidth="76"/>
|
||||||
|
<Button Content="Schedule" Classes="accent" Click="OnScheduleSetClick" MinWidth="76"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Flyout>
|
||||||
|
</Button.Flyout>
|
||||||
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,16 +1,72 @@
|
|||||||
|
using System.Linq;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Animation;
|
using Avalonia.Animation;
|
||||||
using Avalonia.Animation.Easings;
|
using Avalonia.Animation.Easings;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands;
|
namespace ClaudeDo.Ui.Views.Islands;
|
||||||
|
|
||||||
public partial class TaskRowView : UserControl
|
public partial class TaskRowView : UserControl
|
||||||
{
|
{
|
||||||
|
private TaskRowViewModel? _pendingScheduleRow;
|
||||||
|
|
||||||
public TaskRowView() { InitializeComponent(); }
|
public TaskRowView() { InitializeComponent(); }
|
||||||
|
|
||||||
|
private TasksIslandViewModel? FindTasksVm() =>
|
||||||
|
this.GetVisualAncestors().OfType<ItemsControl>()
|
||||||
|
.Select(ic => ic.DataContext).OfType<TasksIslandViewModel>().FirstOrDefault();
|
||||||
|
|
||||||
|
private async void OnSendToQueueClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.SendToQueueCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnRemoveFromQueueClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.RemoveFromQueueCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnClearScheduleClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.ClearScheduleCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not TaskRowViewModel row) return;
|
||||||
|
_pendingScheduleRow = row;
|
||||||
|
var seed = row.ScheduledFor ?? DateTime.Now.AddHours(1);
|
||||||
|
ScheduleDate.SelectedDate = new DateTimeOffset(seed.Date, TimeSpan.Zero);
|
||||||
|
ScheduleTime.SelectedTime = seed.TimeOfDay;
|
||||||
|
ScheduleAnchor.Flyout?.ShowAt(ScheduleAnchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnScheduleSetClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ScheduleAnchor.Flyout?.Hide();
|
||||||
|
if (_pendingScheduleRow is null || ScheduleDate.SelectedDate is null) return;
|
||||||
|
var date = ScheduleDate.SelectedDate.Value.Date;
|
||||||
|
var time = ScheduleTime.SelectedTime ?? TimeSpan.FromHours(9);
|
||||||
|
var when = date + time;
|
||||||
|
if (FindTasksVm() is { } tvm)
|
||||||
|
await tvm.SetScheduledForAsync(_pendingScheduleRow, when);
|
||||||
|
_pendingScheduleRow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnScheduleCancelClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ScheduleAnchor.Flyout?.Hide();
|
||||||
|
_pendingScheduleRow = null;
|
||||||
|
}
|
||||||
|
|
||||||
protected override async void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
protected override async void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnAttachedToVisualTree(e);
|
base.OnAttachedToVisualTree(e);
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ public partial class TasksIslandView : UserControl
|
|||||||
public TasksIslandView()
|
public TasksIslandView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
// Tunnel handler runs BEFORE Button's class handler so we can start a drag
|
|
||||||
// without the Button first marking the event as handled.
|
|
||||||
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
|
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
|
||||||
DataContextChanged += (_, _) =>
|
DataContextChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
@@ -27,14 +25,25 @@ public partial class TasksIslandView : UserControl
|
|||||||
|
|
||||||
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
|
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not TasksIslandViewModel vm || !vm.CanReorder) return;
|
if (DataContext is not TasksIslandViewModel vm) return;
|
||||||
if (e.Source is not Visual src) return;
|
if (e.Source is not Visual src) return;
|
||||||
|
|
||||||
var button = src as Button ?? src.FindAncestorOfType<Button>();
|
var button = src as Button ?? src.FindAncestorOfType<Button>();
|
||||||
if (button?.DataContext is not TaskRowViewModel row) return;
|
if (button?.DataContext is not TaskRowViewModel row) return;
|
||||||
if (row.IsRunning) return;
|
|
||||||
if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return;
|
if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return;
|
||||||
|
|
||||||
|
// Select now so the details pane updates whether the gesture becomes a click or a drag.
|
||||||
|
// (Button.Click doesn't fire once DoDragDropAsync captures the pointer.)
|
||||||
|
vm.SelectedTask = row;
|
||||||
|
|
||||||
|
// If the click landed on a nested Button (e.g. the done-toggle checkbox or star),
|
||||||
|
// don't start a drag — that would capture the pointer and swallow the inner Click.
|
||||||
|
var nestedInsideButton = button.Parent is Visual parentVisual
|
||||||
|
&& parentVisual.FindAncestorOfType<Button>() is not null;
|
||||||
|
if (nestedInsideButton) return;
|
||||||
|
|
||||||
|
if (!vm.CanReorder || row.IsRunning) return;
|
||||||
|
|
||||||
var data = new DataTransfer();
|
var data = new DataTransfer();
|
||||||
data.Add(DataTransferItem.Create(TaskRowFormat, row.Id));
|
data.Add(DataTransferItem.Create(TaskRowFormat, row.Id));
|
||||||
try
|
try
|
||||||
@@ -133,11 +142,18 @@ public partial class TasksIslandView : UserControl
|
|||||||
if (source is null || source.IsRunning) return;
|
if (source is null || source.IsRunning) return;
|
||||||
|
|
||||||
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
|
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
|
||||||
|
|
||||||
|
// Clear the 6px drop-hint spacer BEFORE the move so the reorder animates
|
||||||
|
// into its truly-final layout in one step (otherwise the row lands in the
|
||||||
|
// gap, then the gap collapses and everything shifts up a second time).
|
||||||
|
vm.ClearDropHints();
|
||||||
|
|
||||||
await vm.ReorderAsync(source, target, placeBelow);
|
await vm.ReorderAsync(source, target, placeBelow);
|
||||||
}
|
}
|
||||||
finally
|
catch
|
||||||
{
|
{
|
||||||
vm.ClearDropHints();
|
vm.ClearDropHints();
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
Title="ClaudeDo"
|
Title="ClaudeDo"
|
||||||
Width="1280" Height="820" MinWidth="780" MinHeight="600"
|
Width="1280" Height="820" MinWidth="780" MinHeight="600"
|
||||||
Background="{DynamicResource VoidBrush}"
|
Background="{DynamicResource VoidBrush}"
|
||||||
SystemDecorations="None"
|
Icon="avares://ClaudeDo.Ui/Assets/ClaudeTask.ico"
|
||||||
|
CanResize="True"
|
||||||
|
SystemDecorations="BorderOnly"
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
ExtendClientAreaTitleBarHeightHint="-1">
|
ExtendClientAreaTitleBarHeightHint="-1">
|
||||||
<Window.KeyBindings>
|
<Window.KeyBindings>
|
||||||
@@ -26,11 +28,11 @@
|
|||||||
<!-- Left: brand block -->
|
<!-- Left: brand block -->
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
|
||||||
VerticalAlignment="Center" Margin="14,0,0,0">
|
VerticalAlignment="Center" Margin="14,0,0,0">
|
||||||
<!-- Green checkbox glyph -->
|
<!-- App icon (matches taskbar) -->
|
||||||
<PathIcon Classes="title-brand-icon"
|
<Image Source="avares://ClaudeDo.Ui/Assets/ClaudeTask.ico"
|
||||||
Data="{StaticResource Icon.BrandCheck}"
|
Width="16" Height="16"
|
||||||
Width="14" Height="14"
|
VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource MossBrush}" />
|
RenderOptions.BitmapInterpolationMode="HighQuality"/>
|
||||||
<!-- CLAUDEDO label -->
|
<!-- CLAUDEDO label -->
|
||||||
<TextBlock Classes="title-brand-name"
|
<TextBlock Classes="title-brand-name"
|
||||||
Text="CLAUDEDO"
|
Text="CLAUDEDO"
|
||||||
|
|||||||
@@ -4,77 +4,177 @@
|
|||||||
x:Class="ClaudeDo.Ui.Views.Modals.ListSettingsModalView"
|
x:Class="ClaudeDo.Ui.Views.Modals.ListSettingsModalView"
|
||||||
x:DataType="vm:ListSettingsModalViewModel"
|
x:DataType="vm:ListSettingsModalViewModel"
|
||||||
Title="List settings"
|
Title="List settings"
|
||||||
Width="520" Height="600"
|
Width="520" Height="720"
|
||||||
|
CanResize="True"
|
||||||
|
MinWidth="460" MinHeight="520"
|
||||||
|
SystemDecorations="None"
|
||||||
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
CanResize="False">
|
Background="{DynamicResource SurfaceBrush}">
|
||||||
<DockPanel Margin="16">
|
|
||||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
|
|
||||||
<Button Content="Cancel" Command="{Binding CancelCommand}" />
|
|
||||||
<Button Content="Save" Command="{Binding SaveCommand}" Classes="accent" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<ScrollViewer>
|
<Window.KeyBindings>
|
||||||
<StackPanel Spacing="16">
|
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
|
||||||
<TextBlock Text="General" FontSize="16" FontWeight="SemiBold" />
|
</Window.KeyBindings>
|
||||||
|
|
||||||
|
<Window.Styles>
|
||||||
|
<Style Selector="TextBlock.section-label">
|
||||||
|
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
|
||||||
|
<Setter Property="FontSize" Value="10"/>
|
||||||
|
<Setter Property="LetterSpacing" Value="1.4"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextFaintBrush}"/>
|
||||||
|
<Setter Property="Margin" Value="4,0,0,6"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.field-label">
|
||||||
|
<Setter Property="FontSize" Value="11"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,0,4"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.section">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="CornerRadius" Value="6"/>
|
||||||
|
<Setter Property="Padding" Value="14"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<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="LIST SETTINGS"
|
||||||
|
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 -->
|
||||||
|
<ScrollViewer Grid.Row="1" Padding="20,16">
|
||||||
|
<StackPanel Spacing="18">
|
||||||
|
|
||||||
|
<!-- GENERAL -->
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
<TextBlock Classes="section-label" Text="GENERAL"/>
|
||||||
|
<Border Classes="section">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Name" />
|
<TextBlock Classes="field-label" Text="Name"/>
|
||||||
<TextBox Text="{Binding Name}" />
|
<TextBox Text="{Binding Name}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Working directory" />
|
<TextBlock Classes="field-label" Text="Working directory"/>
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="(none)" />
|
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" Watermark="(none)" />
|
||||||
<Button Grid.Column="1" Content="Browse..." Margin="8,0,0,0" Click="BrowseClicked" />
|
<Button Grid.Column="1" Content="Browse..." Margin="8,0,0,0" Click="BrowseClicked" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Default commit type" />
|
<TextBlock Classes="field-label" Text="Default commit type"/>
|
||||||
<ComboBox ItemsSource="{Binding CommitTypeOptions}"
|
<ComboBox ItemsSource="{Binding CommitTypeOptions}"
|
||||||
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
|
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
|
||||||
HorizontalAlignment="Left" MinWidth="160" />
|
HorizontalAlignment="Left" MinWidth="160" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<Separator Margin="0,8,0,8" />
|
<!-- AGENT -->
|
||||||
|
<StackPanel Spacing="0">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto" Margin="4,0,0,6">
|
||||||
<TextBlock Grid.Column="0" Text="Agent" FontSize="16" FontWeight="SemiBold" />
|
<TextBlock Classes="section-label" Text="AGENT" Margin="0"/>
|
||||||
<Button Grid.Column="1" Content="Reset agent settings"
|
<Button Grid.Column="1" Content="Reset agent settings"
|
||||||
Command="{Binding ResetAgentSettingsCommand}" />
|
Command="{Binding ResetAgentSettingsCommand}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Border Classes="section">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Model" />
|
<TextBlock Classes="field-label" Text="Model"/>
|
||||||
<ComboBox ItemsSource="{Binding ModelOptions}"
|
<ComboBox ItemsSource="{Binding ModelOptions}"
|
||||||
SelectedItem="{Binding SelectedModel, Mode=TwoWay}"
|
SelectedItem="{Binding SelectedModel, Mode=TwoWay}"
|
||||||
HorizontalAlignment="Left" MinWidth="160" />
|
HorizontalAlignment="Left" MinWidth="160" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="System prompt (appended)" />
|
<TextBlock Classes="field-label" Text="System prompt (appended)"/>
|
||||||
<TextBox Text="{Binding SystemPrompt, Mode=TwoWay}"
|
<TextBox Text="{Binding SystemPrompt, Mode=TwoWay}"
|
||||||
AcceptsReturn="True" TextWrapping="Wrap"
|
AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
MinHeight="80" />
|
MinHeight="80" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Agent file" />
|
<TextBlock Classes="field-label" Text="Agent file"/>
|
||||||
<ComboBox ItemsSource="{Binding Agents}"
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<ComboBox Grid.Column="0"
|
||||||
|
ItemsSource="{Binding Agents}"
|
||||||
SelectedItem="{Binding SelectedAgent, Mode=TwoWay}"
|
SelectedItem="{Binding SelectedAgent, Mode=TwoWay}"
|
||||||
HorizontalAlignment="Left" MinWidth="240">
|
HorizontalAlignment="Stretch">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="{Binding Name}" />
|
<TextBlock Text="{Binding Name}"
|
||||||
<TextBlock Text="{Binding Description}" Opacity="0.6" FontSize="11" />
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
</StackPanel>
|
<TextBlock Text="{Binding Description}"
|
||||||
</DataTemplate>
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
</ComboBox.ItemTemplate>
|
FontSize="11" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
<Button Grid.Column="1" Content="Browse..."
|
||||||
|
Margin="8,0,0,0" Click="BrowseAgentClicked" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{Binding SelectedAgent.Path}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"
|
||||||
|
TextTrimming="PrefixCharacterEllipsis"
|
||||||
|
IsVisible="{Binding SelectedAgent.Path, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</Border>
|
||||||
</DockPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- 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="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
|
||||||
|
<Button Content="Save" Classes="primary" Command="{Binding SaveCommand}" MinWidth="90"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Modals;
|
namespace ClaudeDo.Ui.Views.Modals;
|
||||||
@@ -12,6 +14,63 @@ public partial class ListSettingsModalView : Window
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
BeginMoveDrag(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void BrowseAgentClicked(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not ListSettingsModalViewModel vm) return;
|
||||||
|
var top = TopLevel.GetTopLevel(this);
|
||||||
|
if (top is null) return;
|
||||||
|
|
||||||
|
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Choose agent file",
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter = new[]
|
||||||
|
{
|
||||||
|
new FilePickerFileType("Agent files (*.md)") { Patterns = new[] { "*.md" } },
|
||||||
|
new FilePickerFileType("All files") { Patterns = new[] { "*" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (files.Count == 0) return;
|
||||||
|
|
||||||
|
var path = files[0].Path.LocalPath;
|
||||||
|
var existing = vm.Agents.FirstOrDefault(a => string.Equals(a.Path, path, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
vm.SelectedAgent = existing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (name, description) = ReadFrontmatter(path);
|
||||||
|
var agent = new AgentInfo(name, description, path);
|
||||||
|
vm.Agents.Add(agent);
|
||||||
|
vm.SelectedAgent = agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string name, string description) ReadFrontmatter(string filePath)
|
||||||
|
{
|
||||||
|
var fallback = System.IO.Path.GetFileNameWithoutExtension(filePath);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new System.IO.StreamReader(filePath);
|
||||||
|
if (reader.ReadLine()?.Trim() != "---") return (fallback, "");
|
||||||
|
string name = fallback, description = "";
|
||||||
|
while (reader.ReadLine() is { } line)
|
||||||
|
{
|
||||||
|
if (line.Trim() == "---") break;
|
||||||
|
if (line.StartsWith("name:")) name = line["name:".Length..].Trim();
|
||||||
|
else if (line.StartsWith("description:")) description = line["description:".Length..].Trim();
|
||||||
|
}
|
||||||
|
return (name, description);
|
||||||
|
}
|
||||||
|
catch { return (fallback, ""); }
|
||||||
|
}
|
||||||
|
|
||||||
private async void BrowseClicked(object? sender, RoutedEventArgs e)
|
private async void BrowseClicked(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not ListSettingsModalViewModel vm) return;
|
if (DataContext is not ListSettingsModalViewModel vm) return;
|
||||||
|
|||||||
@@ -4,77 +4,135 @@
|
|||||||
x:Class="ClaudeDo.Ui.Views.Modals.MergeModalView"
|
x:Class="ClaudeDo.Ui.Views.Modals.MergeModalView"
|
||||||
x:DataType="vm:MergeModalViewModel"
|
x:DataType="vm:MergeModalViewModel"
|
||||||
Title="Merge worktree"
|
Title="Merge worktree"
|
||||||
Width="560" Height="420"
|
Width="560" Height="460"
|
||||||
CanResize="False"
|
CanResize="False"
|
||||||
WindowStartupLocation="CenterOwner">
|
SystemDecorations="None"
|
||||||
<Grid Margin="20" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource SurfaceBrush}">
|
||||||
|
|
||||||
<TextBlock Grid.Row="0"
|
<Window.KeyBindings>
|
||||||
Text="{Binding TaskTitle, StringFormat='Merging: {0}'}"
|
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
|
||||||
FontWeight="SemiBold" Margin="0,0,0,12" />
|
</Window.KeyBindings>
|
||||||
|
|
||||||
<StackPanel Grid.Row="1" Orientation="Vertical" Margin="0,0,0,8">
|
<Window.Styles>
|
||||||
<TextBlock Text="Target branch" Margin="0,0,0,4" />
|
<Style Selector="TextBlock.field-label">
|
||||||
<ComboBox ItemsSource="{Binding Branches}"
|
<Setter Property="FontSize" Value="11"/>
|
||||||
SelectedItem="{Binding SelectedBranch}"
|
<Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
|
||||||
HorizontalAlignment="Stretch"
|
<Setter Property="Margin" Value="0,0,0,4"/>
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
</Style>
|
||||||
</StackPanel>
|
<Style Selector="Button.primary">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource DeepBrush}"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
</Style>
|
||||||
|
</Window.Styles>
|
||||||
|
|
||||||
<CheckBox Grid.Row="2"
|
<Border Background="{DynamicResource SurfaceBrush}"
|
||||||
Content="Remove worktree after merge"
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
IsChecked="{Binding RemoveWorktree}"
|
BorderThickness="1">
|
||||||
IsEnabled="{Binding !IsBusy}"
|
<Grid RowDefinitions="36,*,52">
|
||||||
Margin="0,0,0,8" />
|
|
||||||
|
|
||||||
<StackPanel Grid.Row="3" Orientation="Vertical" Margin="0,0,0,8">
|
<!-- Title bar -->
|
||||||
<TextBlock Text="Commit message" Margin="0,0,0,4" />
|
<Border Grid.Row="0"
|
||||||
<TextBox Text="{Binding CommitMessage}"
|
x:Name="TitleBar"
|
||||||
AcceptsReturn="True"
|
Background="{DynamicResource DeepBrush}"
|
||||||
TextWrapping="Wrap"
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
Height="70"
|
BorderThickness="0,0,0,1"
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
PointerPressed="TitleBar_PointerPressed">
|
||||||
</StackPanel>
|
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
|
||||||
|
<TextBlock Text="MERGE WORKTREE"
|
||||||
|
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>
|
||||||
|
|
||||||
<TextBlock Grid.Row="4"
|
<!-- Body -->
|
||||||
Text="{Binding ErrorMessage}"
|
<ScrollViewer Grid.Row="1" Padding="20,16">
|
||||||
Foreground="IndianRed"
|
<StackPanel Spacing="12">
|
||||||
TextWrapping="Wrap"
|
|
||||||
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}"
|
|
||||||
Margin="0,0,0,8" />
|
|
||||||
|
|
||||||
<Border Grid.Row="5"
|
<TextBlock Text="{Binding TaskTitle, StringFormat='Merging: {0}'}"
|
||||||
BorderBrush="IndianRed"
|
FontWeight="SemiBold"
|
||||||
BorderThickness="1"
|
Foreground="{DynamicResource TextBrush}" />
|
||||||
Padding="8"
|
|
||||||
IsVisible="{Binding HasConflict}">
|
|
||||||
<StackPanel>
|
|
||||||
<TextBlock Text="Conflicted files:" FontWeight="SemiBold" Margin="0,0,0,4" />
|
|
||||||
<ItemsControl ItemsSource="{Binding ConflictFiles}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<TextBlock Text="{Binding}" />
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<StackPanel Grid.Row="6" Orientation="Horizontal"
|
<StackPanel Spacing="4">
|
||||||
HorizontalAlignment="Right" Margin="0,12,0,0">
|
<TextBlock Classes="field-label" Text="Target branch"/>
|
||||||
<TextBlock Text="{Binding SuccessMessage}"
|
<ComboBox ItemsSource="{Binding Branches}"
|
||||||
Foreground="SeaGreen"
|
SelectedItem="{Binding SelectedBranch}"
|
||||||
VerticalAlignment="Center"
|
HorizontalAlignment="Stretch"
|
||||||
Margin="0,0,12,0"
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
IsVisible="{Binding SuccessMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
</StackPanel>
|
||||||
<Button Content="Cancel"
|
|
||||||
Command="{Binding CancelCommand}"
|
|
||||||
Margin="0,0,8,0" />
|
|
||||||
<Button Content="Merge"
|
|
||||||
Command="{Binding SubmitCommand}"
|
|
||||||
IsDefault="True"
|
|
||||||
Classes="accent" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
</Grid>
|
<CheckBox Content="Remove worktree after merge"
|
||||||
|
IsChecked="{Binding RemoveWorktree}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="Commit message"/>
|
||||||
|
<TextBox Text="{Binding CommitMessage}"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Height="70"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding ErrorMessage}"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource BloodBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="6"
|
||||||
|
Padding="12,10"
|
||||||
|
IsVisible="{Binding HasConflict}">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Conflicted files:"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextBrush}" />
|
||||||
|
<ItemsControl ItemsSource="{Binding ConflictFiles}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding SuccessMessage}"
|
||||||
|
Foreground="{DynamicResource MossBrightBrush}"
|
||||||
|
IsVisible="{Binding SuccessMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- 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="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
|
||||||
|
<Button Content="Merge" Classes="primary"
|
||||||
|
Command="{Binding SubmitCommand}"
|
||||||
|
IsDefault="True" MinWidth="90"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Modals;
|
namespace ClaudeDo.Ui.Views.Modals;
|
||||||
@@ -16,4 +17,10 @@ public partial class MergeModalView : Window
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
BeginMoveDrag(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,23 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- AGENTS -->
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
<TextBlock Classes="section-label" Text="AGENTS"/>
|
||||||
|
<Border Classes="section">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Restore bundled default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher). Existing files are not overwritten."
|
||||||
|
FontSize="11"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<Button Content="Restore default agents"
|
||||||
|
Command="{Binding RestoreDefaultAgentsCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- ABOUT -->
|
<!-- ABOUT -->
|
||||||
<StackPanel Spacing="0">
|
<StackPanel Spacing="0">
|
||||||
<TextBlock Classes="section-label" Text="ABOUT"/>
|
<TextBlock Classes="section-label" Text="ABOUT"/>
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="DefaultAgents\*.md">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
19
src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md
Normal file
19
src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: code-reviewer
|
||||||
|
description: Reviews code changes for bugs, logic errors, and convention violations. Flags only high-confidence issues.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a code reviewer. Your job is to inspect the diff for real problems, not nitpicks.
|
||||||
|
|
||||||
|
Focus on:
|
||||||
|
- Logic errors, off-by-one bugs, null/empty handling
|
||||||
|
- Broken invariants, race conditions, resource leaks
|
||||||
|
- Violations of the project's established conventions (read nearby code first)
|
||||||
|
- Missing error handling at system boundaries (external input, IO, network)
|
||||||
|
|
||||||
|
Skip:
|
||||||
|
- Style preferences the codebase doesn't enforce
|
||||||
|
- Speculative "what if" concerns
|
||||||
|
- Renaming for its own sake
|
||||||
|
|
||||||
|
Output: a short list of concrete issues with file:line references. If the diff is clean, say so in one sentence. Do not rewrite the code — call out the problem and let the implementer fix it.
|
||||||
20
src/ClaudeDo.Worker/DefaultAgents/debugger.md
Normal file
20
src/ClaudeDo.Worker/DefaultAgents/debugger.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: debugger
|
||||||
|
description: Systematic root-cause analysis for bugs, test failures, and unexpected behavior. Hypothesize, isolate, verify.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a debugger. You do NOT guess at fixes — you find the root cause first.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Reproduce. Get a minimal, deterministic repro. If you can't reproduce it, say so and stop.
|
||||||
|
2. Isolate. Narrow the failing path (bisect, binary search, or tracing).
|
||||||
|
3. Hypothesize. State a specific, falsifiable cause.
|
||||||
|
4. Verify. Prove the hypothesis by observation (logs, debugger, targeted print) — not by "this seems likely".
|
||||||
|
5. Fix at the root, not the symptom. If the only fix is a workaround, explain why.
|
||||||
|
|
||||||
|
Anti-patterns to avoid:
|
||||||
|
- Making changes to "see if it works"
|
||||||
|
- Adding try/catch to silence errors
|
||||||
|
- Declaring the bug fixed without reproducing the fix
|
||||||
|
|
||||||
|
Output: repro steps, root cause, and the minimal fix. Include evidence (log excerpt, command output) that proves the cause.
|
||||||
22
src/ClaudeDo.Worker/DefaultAgents/explorer.md
Normal file
22
src/ClaudeDo.Worker/DefaultAgents/explorer.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: explorer
|
||||||
|
description: Fast codebase navigation — find files, search for patterns, answer "where/how" questions. Terse output.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an explorer. Your job is to find things in the codebase quickly and report back concisely.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- Glob/Grep for searches
|
||||||
|
- Read only for files you need to quote from
|
||||||
|
|
||||||
|
Do NOT:
|
||||||
|
- Refactor, edit, or "improve" anything
|
||||||
|
- Read files that aren't relevant to the question
|
||||||
|
- Dump raw tool output — summarize
|
||||||
|
|
||||||
|
Output style:
|
||||||
|
- Lead with the answer in one sentence.
|
||||||
|
- Back it up with file:line references.
|
||||||
|
- If you found nothing, say "no match" and what you searched for.
|
||||||
|
|
||||||
|
Keep responses short. The caller wants facts, not prose.
|
||||||
20
src/ClaudeDo.Worker/DefaultAgents/researcher.md
Normal file
20
src/ClaudeDo.Worker/DefaultAgents/researcher.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: researcher
|
||||||
|
description: General-purpose research and analysis for non-code tasks — summarize docs, investigate questions, draft prose.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a researcher. You handle tasks that don't fit the code-review/test/debug shape.
|
||||||
|
|
||||||
|
Good fits:
|
||||||
|
- Summarizing documents, specs, or long outputs
|
||||||
|
- Investigating an open question (what does X do, how does Y work, what are the tradeoffs)
|
||||||
|
- Drafting non-code text (release notes, emails, docs)
|
||||||
|
- Analyzing structured data (logs, CSV, JSON) and reporting findings
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Restate the task in one sentence so you know what "done" looks like.
|
||||||
|
2. Gather just enough information — stop when you can answer, not when you run out of sources.
|
||||||
|
3. Distinguish facts ("the file says X") from inference ("so likely Y").
|
||||||
|
4. Cite sources (file:line, URL, log excerpt) for every claim.
|
||||||
|
|
||||||
|
Output: direct answer first, supporting evidence second. Keep it short unless asked for depth.
|
||||||
20
src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md
Normal file
20
src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: security-reviewer
|
||||||
|
description: Audits code for OWASP-class security issues — auth, injection, input handling, secret exposure.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a security reviewer. Focus on real, exploitable weaknesses — not theoretical hardening.
|
||||||
|
|
||||||
|
Check for:
|
||||||
|
- Injection: SQL, command, path traversal, XSS, template injection
|
||||||
|
- Auth: missing authorization, token handling, session fixation
|
||||||
|
- Input validation at system boundaries (HTTP, files, IPC)
|
||||||
|
- Secrets: hardcoded credentials, tokens in logs, leaked env vars
|
||||||
|
- Unsafe deserialization, XXE, SSRF
|
||||||
|
- Cryptography misuse (custom crypto, weak algorithms, fixed IVs)
|
||||||
|
|
||||||
|
Ignore:
|
||||||
|
- Internal trust-boundary assumptions the project already documents
|
||||||
|
- Defense-in-depth ideas with no concrete attack path
|
||||||
|
|
||||||
|
Output: a prioritized list — severity, file:line, the exploit path, the fix. If nothing is wrong, say so plainly.
|
||||||
19
src/ClaudeDo.Worker/DefaultAgents/test-writer.md
Normal file
19
src/ClaudeDo.Worker/DefaultAgents/test-writer.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: test-writer
|
||||||
|
description: Generates unit and integration tests for existing or new code. Follows the project's test patterns and frameworks.
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a test-writer. Your job is to write focused, useful tests for code under review.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Read the target code and identify the observable behavior.
|
||||||
|
2. Read existing tests nearby to match the framework, fixtures, naming, and assertion style.
|
||||||
|
3. Write tests covering the happy path, boundary conditions, and the specific failure modes that matter.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- One behavior per test. Clear Arrange/Act/Assert.
|
||||||
|
- No tests for private implementation details — exercise public API.
|
||||||
|
- No mocks where real objects are cheap (in-memory DBs, temp dirs).
|
||||||
|
- Skip trivially-correct tests (getter returns what you set).
|
||||||
|
|
||||||
|
Output: the test file(s) ready to compile, matching the project's conventions. Include the command to run them.
|
||||||
@@ -28,6 +28,7 @@ public record UpdateListDto(string Id, string Name, string? WorkingDir, string D
|
|||||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath);
|
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath);
|
||||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
|
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
|
||||||
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
|
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
|
||||||
|
public record SeedResultDto(int Copied, int Skipped);
|
||||||
|
|
||||||
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||||
{
|
{
|
||||||
@@ -36,6 +37,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
|
|
||||||
private readonly QueueService _queue;
|
private readonly QueueService _queue;
|
||||||
private readonly AgentFileService _agentService;
|
private readonly AgentFileService _agentService;
|
||||||
|
private readonly DefaultAgentSeeder _seeder;
|
||||||
private readonly HubBroadcaster _broadcaster;
|
private readonly HubBroadcaster _broadcaster;
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly WorktreeMaintenanceService _wtMaintenance;
|
private readonly WorktreeMaintenanceService _wtMaintenance;
|
||||||
@@ -45,6 +47,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
public WorkerHub(
|
public WorkerHub(
|
||||||
QueueService queue,
|
QueueService queue,
|
||||||
AgentFileService agentService,
|
AgentFileService agentService,
|
||||||
|
DefaultAgentSeeder seeder,
|
||||||
HubBroadcaster broadcaster,
|
HubBroadcaster broadcaster,
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
WorktreeMaintenanceService wtMaintenance,
|
WorktreeMaintenanceService wtMaintenance,
|
||||||
@@ -53,6 +56,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
{
|
{
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_agentService = agentService;
|
_agentService = agentService;
|
||||||
|
_seeder = seeder;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_wtMaintenance = wtMaintenance;
|
_wtMaintenance = wtMaintenance;
|
||||||
@@ -125,6 +129,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
|
|
||||||
public async Task RefreshAgents() => await _agentService.ScanAsync();
|
public async Task RefreshAgents() => await _agentService.ScanAsync();
|
||||||
|
|
||||||
|
public async Task<SeedResultDto> RestoreDefaultAgents()
|
||||||
|
{
|
||||||
|
var result = await _seeder.SeedMissingAsync();
|
||||||
|
return new SeedResultDto(result.Copied, result.Skipped);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<AppSettingsDto> GetAppSettings()
|
public async Task<AppSettingsDto> GetAppSettings()
|
||||||
{
|
{
|
||||||
using var ctx = _dbFactory.CreateDbContext();
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
|||||||
Directory.CreateDirectory(agentsDir);
|
Directory.CreateDirectory(agentsDir);
|
||||||
builder.Services.AddSingleton(new AgentFileService(agentsDir));
|
builder.Services.AddSingleton(new AgentFileService(agentsDir));
|
||||||
|
|
||||||
|
var defaultAgentsBundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
|
||||||
|
builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
|
||||||
|
defaultAgentsBundleDir,
|
||||||
|
agentsDir,
|
||||||
|
sp.GetService<Microsoft.Extensions.Logging.ILogger<DefaultAgentSeeder>>()));
|
||||||
|
|
||||||
// QueueService: singleton + hosted service (same instance).
|
// QueueService: singleton + hosted service (same instance).
|
||||||
builder.Services.AddSingleton<QueueService>();
|
builder.Services.AddSingleton<QueueService>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
|
||||||
@@ -53,6 +59,19 @@ using (var scope = app.Services.CreateScope())
|
|||||||
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
|
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var seeder = app.Services.GetRequiredService<DefaultAgentSeeder>();
|
||||||
|
var seedResult = await seeder.SeedMissingAsync();
|
||||||
|
app.Logger.LogInformation(
|
||||||
|
"Default agents seeded: {Copied} copied, {Skipped} already present",
|
||||||
|
seedResult.Copied, seedResult.Skipped);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
app.Logger.LogWarning(ex, "Default agent seeding failed");
|
||||||
|
}
|
||||||
|
|
||||||
app.MapHub<WorkerHub>("/hub");
|
app.MapHub<WorkerHub>("/hub");
|
||||||
|
|
||||||
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
||||||
|
|||||||
60
src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs
Normal file
60
src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Services;
|
||||||
|
|
||||||
|
public sealed record SeedResult(int Copied, int Skipped);
|
||||||
|
|
||||||
|
public sealed class DefaultAgentSeeder
|
||||||
|
{
|
||||||
|
private readonly string _bundleDir;
|
||||||
|
private readonly string _targetDir;
|
||||||
|
private readonly ILogger<DefaultAgentSeeder>? _logger;
|
||||||
|
|
||||||
|
public DefaultAgentSeeder(string bundleDir, string targetDir, ILogger<DefaultAgentSeeder>? logger = null)
|
||||||
|
{
|
||||||
|
_bundleDir = bundleDir;
|
||||||
|
_targetDir = targetDir;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SeedResult> SeedMissingAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_bundleDir))
|
||||||
|
{
|
||||||
|
_logger?.LogWarning("DefaultAgents bundle dir not found: {Dir}", _bundleDir);
|
||||||
|
return new SeedResult(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_targetDir);
|
||||||
|
|
||||||
|
int copied = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
foreach (var src in Directory.EnumerateFiles(_bundleDir, "*.md"))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var fileName = Path.GetFileName(src);
|
||||||
|
var dst = Path.Combine(_targetDir, fileName);
|
||||||
|
|
||||||
|
if (File.Exists(dst))
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var input = File.OpenRead(src);
|
||||||
|
using var output = File.Create(dst);
|
||||||
|
await input.CopyToAsync(output, ct);
|
||||||
|
copied++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "Failed to copy default agent {File}", fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SeedResult(copied, skipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,4 +44,29 @@ public sealed class AgentSettingsHubTests : IDisposable
|
|||||||
|
|
||||||
Assert.Null(await _repo.GetConfigAsync(listId));
|
Assert.Null(await _repo.GetConfigAsync(listId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RestoreDefaultAgents_CopiesMissingBundledFiles()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"claudedo_hub_restore_{Guid.NewGuid():N}");
|
||||||
|
var bundleDir = Path.Combine(root, "bundle");
|
||||||
|
var targetDir = Path.Combine(root, "target");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(bundleDir);
|
||||||
|
Directory.CreateDirectory(targetDir);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(bundleDir, "code-reviewer.md"), "body");
|
||||||
|
|
||||||
|
var seeder = new ClaudeDo.Worker.Services.DefaultAgentSeeder(bundleDir, targetDir);
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Copied);
|
||||||
|
Assert.Equal(0, result.Skipped);
|
||||||
|
Assert.True(File.Exists(Path.Combine(targetDir, "code-reviewer.md")));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(root, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
112
tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs
Normal file
112
tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
using ClaudeDo.Worker.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Services;
|
||||||
|
|
||||||
|
public sealed class DefaultAgentSeederTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _bundleDir;
|
||||||
|
private readonly string _targetDir;
|
||||||
|
|
||||||
|
public DefaultAgentSeederTests()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"claudedo_seeder_{Guid.NewGuid():N}");
|
||||||
|
_bundleDir = Path.Combine(root, "bundle");
|
||||||
|
_targetDir = Path.Combine(root, "target");
|
||||||
|
Directory.CreateDirectory(_bundleDir);
|
||||||
|
Directory.CreateDirectory(_targetDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(Path.GetDirectoryName(_bundleDir)!, true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteBundleAsync(string name, string content)
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(_bundleDir, name), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_CopiesAllFiles_WhenTargetEmpty()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "A");
|
||||||
|
await WriteBundleAsync("b.md", "B");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Copied);
|
||||||
|
Assert.Equal(0, result.Skipped);
|
||||||
|
Assert.True(File.Exists(Path.Combine(_targetDir, "a.md")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(_targetDir, "b.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_SkipsExistingFiles()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "bundled");
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "user-modified");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Copied);
|
||||||
|
Assert.Equal(1, result.Skipped);
|
||||||
|
var content = await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md"));
|
||||||
|
Assert.Equal("user-modified", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_MixedState_CopiesOnlyMissing()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "A");
|
||||||
|
await WriteBundleAsync("b.md", "B");
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "existing");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Copied);
|
||||||
|
Assert.Equal(1, result.Skipped);
|
||||||
|
Assert.Equal("existing", await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md")));
|
||||||
|
Assert.Equal("B", await File.ReadAllTextAsync(Path.Combine(_targetDir, "b.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_ReturnsZero_WhenBundleDirMissing()
|
||||||
|
{
|
||||||
|
var missingBundle = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
|
||||||
|
var seeder = new DefaultAgentSeeder(missingBundle, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Copied);
|
||||||
|
Assert.Equal(0, result.Skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_CreatesTargetDir_IfMissing()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "A");
|
||||||
|
var missingTarget = Path.Combine(_targetDir, "nested", "created");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, missingTarget);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Copied);
|
||||||
|
Assert.True(File.Exists(Path.Combine(missingTarget, "a.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMissing_IgnoresNonMarkdownFiles()
|
||||||
|
{
|
||||||
|
await WriteBundleAsync("a.md", "A");
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(_bundleDir, "readme.txt"), "not an agent");
|
||||||
|
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
|
||||||
|
|
||||||
|
var result = await seeder.SeedMissingAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Copied);
|
||||||
|
Assert.False(File.Exists(Path.Combine(_targetDir, "readme.txt")));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user