Files
ClaudeDo/docs/superpowers/plans/2026-04-28-tabbed-settings-prime-claude.md
Mika Kuns 2ff0971dce docs: add design + plan for tabbed settings + Prime Claude
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:46:43 +02:00

2425 lines
88 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Tabbed Settings + Prime Claude — 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:** Restructure the Settings modal into a tabbed layout, move About to the Help menu, and add a "Prime Claude" tab that schedules a daily one-shot `claude -p "ping"` call to start the Claude usage window early.
**Architecture:** New `PrimeScheduleEntity` + repository in `ClaudeDo.Data`. Event-driven `PrimeScheduler : BackgroundService` in `ClaudeDo.Worker` that waits on `Task.Delay(timeUntilNextDue, ct)` and is signaled by hub mutations. Hub adds list/upsert/delete + a `PrimeFired` broadcast event. `SettingsModalViewModel` is split into per-tab VMs. `MainWindow` Help menu gains an "About…" entry that opens a new modal.
**Tech Stack:** .NET 8, EF Core (Sqlite) + migrations, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, SignalR, xUnit.
**Spec:** `docs/superpowers/specs/2026-04-28-tabbed-settings-prime-claude-design.md`
**Build / test reminder (project-specific):** `dotnet build ClaudeDo.slnx` fails on .NET 8 — always build individual csproj files. Run all tests via `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` and `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`.
---
## Task 1: Add `PrimeScheduleEntity` + EF configuration
**Files:**
- Create: `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs`
- Create: `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs`
- Modify: `src/ClaudeDo.Data/ClaudeDoDbContext.cs` (add `DbSet`)
- [ ] **Step 1: Create the entity**
```csharp
// src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs
namespace ClaudeDo.Data.Models;
public sealed class PrimeScheduleEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public TimeSpan TimeOfDay { get; set; }
public bool WorkdaysOnly { get; set; } = true;
public bool Enabled { get; set; } = true;
public DateTimeOffset? LastRunAt { get; set; }
public string? PromptOverride { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
```
- [ ] **Step 2: Create the EF configuration**
```csharp
// src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class PrimeScheduleEntityConfiguration : IEntityTypeConfiguration<PrimeScheduleEntity>
{
public void Configure(EntityTypeBuilder<PrimeScheduleEntity> builder)
{
builder.ToTable("prime_schedules");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property(s => s.StartDate).HasColumnName("start_date").IsRequired();
builder.Property(s => s.EndDate).HasColumnName("end_date").IsRequired();
builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired();
builder.Property(s => s.WorkdaysOnly).HasColumnName("workdays_only").IsRequired().HasDefaultValue(true);
builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true);
builder.Property(s => s.LastRunAt).HasColumnName("last_run_at");
builder.Property(s => s.PromptOverride).HasColumnName("prompt_override");
builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired();
}
}
```
- [ ] **Step 3: Register the DbSet**
In `src/ClaudeDo.Data/ClaudeDoDbContext.cs`, add next to the other `DbSet` properties (after `AppSettings`):
```csharp
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
```
- [ ] **Step 4: Build to verify the model compiles**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
Expected: build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs src/ClaudeDo.Data/ClaudeDoDbContext.cs
git commit -m "feat(data): add PrimeScheduleEntity + configuration"
```
---
## Task 2: Add EF migration `AddPrimeSchedules`
**Files:**
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_AddPrimeSchedules.cs` (auto-generated)
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_AddPrimeSchedules.Designer.cs` (auto-generated)
- Modify: `src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs` (auto-updated)
- [ ] **Step 1: Generate the migration**
Run from repo root:
```bash
dotnet ef migrations add AddPrimeSchedules \
--project src/ClaudeDo.Data/ClaudeDo.Data.csproj \
--startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: three files added/modified under `src/ClaudeDo.Data/Migrations/`. The `Up()` method should `CreateTable("prime_schedules", ...)` with the columns from Task 1.
- [ ] **Step 2: Eyeball the generated `Up()` to verify columns**
Open the generated `<timestamp>_AddPrimeSchedules.cs` and confirm columns: `id` (TEXT PK), `start_date`, `end_date`, `time_of_day`, `workdays_only`, `enabled`, `last_run_at` (nullable), `prompt_override` (nullable), `created_at`.
- [ ] **Step 3: Build worker to confirm migration applies**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Data/Migrations/
git commit -m "feat(data): add AddPrimeSchedules migration"
```
---
## Task 3: Add `PrimeScheduleRepository` (TDD)
**Files:**
- Create: `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs`
- [ ] **Step 1: Write failing repository test**
```csharp
// tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.Repositories;
public class PrimeScheduleRepositoryTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
[Fact]
public async Task Upsert_Then_List_RoundTrips()
{
var id = Guid.NewGuid();
using (var ctx = _db.CreateContext())
{
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
StartDate = new DateOnly(2026, 5, 1),
EndDate = new DateOnly(2026, 6, 30),
TimeOfDay = new TimeSpan(7, 0, 0),
WorkdaysOnly = true,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
}
using var read = _db.CreateContext();
var rows = await new PrimeScheduleRepository(read).ListAsync();
Assert.Single(rows);
Assert.Equal(id, rows[0].Id);
Assert.Equal(new TimeSpan(7, 0, 0), rows[0].TimeOfDay);
}
[Fact]
public async Task UpdateLastRunAt_Persists()
{
var id = Guid.NewGuid();
var when = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2));
using (var ctx = _db.CreateContext())
{
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
StartDate = new DateOnly(2026, 5, 1),
EndDate = new DateOnly(2026, 5, 31),
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
}
using (var ctx = _db.CreateContext())
await new PrimeScheduleRepository(ctx).UpdateLastRunAsync(id, when);
using var read = _db.CreateContext();
var row = await new PrimeScheduleRepository(read).GetAsync(id);
Assert.NotNull(row);
Assert.Equal(when, row!.LastRunAt);
}
[Fact]
public async Task Delete_Removes_Row()
{
var id = Guid.NewGuid();
using (var ctx = _db.CreateContext())
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
StartDate = new DateOnly(2026, 5, 1),
EndDate = new DateOnly(2026, 5, 1),
TimeOfDay = TimeSpan.Zero,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
using (var ctx = _db.CreateContext())
await new PrimeScheduleRepository(ctx).DeleteAsync(id);
using var read = _db.CreateContext();
Assert.Empty(await new PrimeScheduleRepository(read).ListAsync());
}
}
```
- [ ] **Step 2: Run test, confirm failure**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PrimeScheduleRepositoryTests"`
Expected: compile error — `PrimeScheduleRepository` does not exist.
- [ ] **Step 3: Implement the repository**
```csharp
// src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class PrimeScheduleRepository
{
private readonly ClaudeDoDbContext _context;
public PrimeScheduleRepository(ClaudeDoDbContext context) => _context = context;
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default) =>
await _context.PrimeSchedules.AsNoTracking()
.OrderBy(s => s.StartDate).ThenBy(s => s.TimeOfDay)
.ToListAsync(ct);
public async Task<PrimeScheduleEntity?> GetAsync(Guid id, CancellationToken ct = default) =>
await _context.PrimeSchedules.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct);
public async Task UpsertAsync(PrimeScheduleEntity entity, CancellationToken ct = default)
{
var existing = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == entity.Id, ct);
if (existing is null)
{
_context.PrimeSchedules.Add(entity);
}
else
{
existing.StartDate = entity.StartDate;
existing.EndDate = entity.EndDate;
existing.TimeOfDay = entity.TimeOfDay;
existing.WorkdaysOnly = entity.WorkdaysOnly;
existing.Enabled = entity.Enabled;
existing.PromptOverride = entity.PromptOverride;
// CreatedAt + LastRunAt are not overwritten by upsert
}
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var row = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == id, ct);
if (row is null) return;
_context.PrimeSchedules.Remove(row);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateLastRunAsync(Guid id, DateTimeOffset when, CancellationToken ct = default)
{
var row = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == id, ct);
if (row is null) return;
row.LastRunAt = when;
await _context.SaveChangesAsync(ct);
}
}
```
- [ ] **Step 4: Run tests, confirm pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PrimeScheduleRepositoryTests"`
Expected: 3 passed.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs
git commit -m "feat(data): add PrimeScheduleRepository"
```
---
## Task 4: Add `PrimeScheduleDto`
**Files:**
- Create: `src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs`
- [ ] **Step 1: Create the folder and DTO**
```csharp
// src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs
namespace ClaudeDo.Worker.Prime;
public sealed record PrimeScheduleDto(
Guid Id,
DateOnly StartDate,
DateOnly EndDate,
TimeSpan TimeOfDay,
bool WorkdaysOnly,
bool Enabled,
DateTimeOffset? LastRunAt,
string? PromptOverride);
```
- [ ] **Step 2: Build to confirm**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs
git commit -m "feat(worker): add PrimeScheduleDto"
```
---
## Task 5: Add scheduler abstractions (`IPrimeClock`, `IPrimeRunner`, `IPrimeScheduleSignal`)
**Files:**
- Create: `src/ClaudeDo.Worker/Prime/IPrimeClock.cs`
- Create: `src/ClaudeDo.Worker/Prime/IPrimeRunner.cs`
- Create: `src/ClaudeDo.Worker/Prime/IPrimeScheduleSignal.cs`
- Create: `src/ClaudeDo.Worker/Prime/PrimeClock.cs`
- Create: `src/ClaudeDo.Worker/Prime/PrimeScheduleSignal.cs`
- Create: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
- Create: `src/ClaudeDo.Worker/Prime/PrimeSchedulerOptions.cs`
- [ ] **Step 1: Clock**
```csharp
// src/ClaudeDo.Worker/Prime/IPrimeClock.cs
namespace ClaudeDo.Worker.Prime;
public interface IPrimeClock { DateTimeOffset Now { get; } }
```
```csharp
// src/ClaudeDo.Worker/Prime/PrimeClock.cs
namespace ClaudeDo.Worker.Prime;
public sealed class PrimeClock : IPrimeClock
{
public DateTimeOffset Now => DateTimeOffset.Now;
}
```
- [ ] **Step 2: Signal (waker for the scheduler)**
```csharp
// src/ClaudeDo.Worker/Prime/IPrimeScheduleSignal.cs
namespace ClaudeDo.Worker.Prime;
public interface IPrimeScheduleSignal
{
void Signal();
CancellationToken CurrentToken { get; }
}
```
```csharp
// src/ClaudeDo.Worker/Prime/PrimeScheduleSignal.cs
namespace ClaudeDo.Worker.Prime;
public sealed class PrimeScheduleSignal : IPrimeScheduleSignal, IDisposable
{
private CancellationTokenSource _cts = new();
private readonly object _lock = new();
public CancellationToken CurrentToken
{
get { lock (_lock) return _cts.Token; }
}
public void Signal()
{
CancellationTokenSource old;
lock (_lock)
{
old = _cts;
_cts = new CancellationTokenSource();
}
try { old.Cancel(); } catch { /* already cancelled */ }
old.Dispose();
}
public void Dispose()
{
lock (_lock) _cts.Dispose();
}
}
```
- [ ] **Step 3: Runner contract**
```csharp
// src/ClaudeDo.Worker/Prime/IPrimeRunner.cs
namespace ClaudeDo.Worker.Prime;
public interface IPrimeRunner
{
Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct);
}
public sealed record PrimeRunOutcome(bool Success, string Message);
```
- [ ] **Step 4: Runner implementation**
```csharp
// src/ClaudeDo.Worker/Prime/PrimeRunner.cs
using ClaudeDo.Data;
using ClaudeDo.Worker.Runner;
namespace ClaudeDo.Worker.Prime;
public sealed class PrimeRunner : IPrimeRunner
{
private static readonly TimeSpan FireTimeout = TimeSpan.FromSeconds(60);
private readonly IClaudeProcess _claude;
private readonly ILogger<PrimeRunner> _logger;
public PrimeRunner(IClaudeProcess claude, ILogger<PrimeRunner> logger)
{
_claude = claude;
_logger = logger;
}
public async Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
{
var cwd = Paths.AppDataRoot();
Directory.CreateDirectory(cwd);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(FireTimeout);
try
{
var prompt = schedule.PromptOverride ?? "ping";
var result = await _claude.RunAsync(
arguments: "-p --max-turns 1",
prompt: prompt,
workingDirectory: cwd,
onStdoutLine: _ => Task.CompletedTask,
ct: timeoutCts.Token);
if (result.ExitCode == 0)
return new PrimeRunOutcome(true, "Primed Claude");
return new PrimeRunOutcome(false, $"exit {result.ExitCode}");
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalSeconds:0}s");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Prime fire failed");
return new PrimeRunOutcome(false, ex.Message);
}
}
}
```
(Note: `RunResult.ExitCode` is the exit code surfaced by `ClaudeProcess`. If the existing `RunResult` shape differs, expose the int via the same property name used internally — this is the only field consumed here.)
- [ ] **Step 5: Options record**
```csharp
// src/ClaudeDo.Worker/Prime/PrimeSchedulerOptions.cs
namespace ClaudeDo.Worker.Prime;
public sealed record PrimeSchedulerOptions(TimeSpan CatchUpWindow)
{
public static PrimeSchedulerOptions Default { get; } =
new(TimeSpan.FromMinutes(30));
}
```
- [ ] **Step 6: Build to confirm**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
If `RunResult` doesn't expose `ExitCode` directly, open `src/ClaudeDo.Worker/Runner/RunResult.cs` and use the equivalent field (e.g. `Status`, `IsSuccess`). The contract is: success when the process exited cleanly. Adjust the `if` branch accordingly and re-build.
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Worker/Prime/
git commit -m "feat(worker): add Prime scheduler abstractions + runner"
```
---
## Task 6: Add `NextDueCalculator` (pure function, TDD)
**Files:**
- Create: `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs`
This pure function is the heart of the scheduler. Isolating it makes the BackgroundService thin and testable.
- [ ] **Step 1: Write failing tests**
```csharp
// tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs
using ClaudeDo.Worker.Prime;
namespace ClaudeDo.Worker.Tests.Prime;
public class NextDueCalculatorTests
{
private static PrimeScheduleDto Schedule(
DateOnly start, DateOnly end, TimeSpan time,
bool workdaysOnly = true, bool enabled = true, DateTimeOffset? lastRun = null) =>
new(Guid.NewGuid(), start, end, time, workdaysOnly, enabled, lastRun, null);
[Fact]
public void Disabled_Schedule_Returns_Null()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), enabled: false);
Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)));
}
[Fact]
public void Future_Same_Day_Returns_Today_At_Target()
{
// Tuesday 06:00 local, target 07:00 same day
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0));
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(new DateTimeOffset(2026,5,5,7,0,0, now.Offset), r!.At);
Assert.False(r.FireImmediately);
}
[Fact]
public void Within_CatchUp_Window_Fires_Immediately()
{
// Tuesday 07:15, target was 07:00, catch-up 30min
var now = new DateTimeOffset(2026, 5, 5, 7, 15, 0, TimeSpan.FromHours(2));
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0));
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.True(r!.FireImmediately);
}
[Fact]
public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day()
{
// Tuesday 09:00, target was 07:00, catch-up 30min → next is Wednesday 07:00
var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0));
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(new DateOnly(2026,5,6), DateOnly.FromDateTime(r!.At.LocalDateTime));
}
[Fact]
public void WorkdaysOnly_Skips_Weekend()
{
// Friday 08:00, target was 07:00 (past catch-up) → next eligible is Monday
var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), workdaysOnly: true);
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(DayOfWeek.Monday, r!.At.LocalDateTime.DayOfWeek);
Assert.Equal(new DateOnly(2026,5,11), DateOnly.FromDateTime(r.At.LocalDateTime));
}
[Fact]
public void Already_Fired_Today_Skips_To_Tomorrow()
{
// Today 06:00, lastRun was today 07:01 → next is tomorrow
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var lastRun = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2));
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), lastRun: lastRun);
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(new DateOnly(2026,5,6), DateOnly.FromDateTime(r!.At.LocalDateTime));
}
[Fact]
public void Past_EndDate_Returns_Null()
{
var now = new DateTimeOffset(2026, 6, 1, 6, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0));
Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)));
}
[Fact]
public void Multiple_Schedules_Returns_Earliest()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var early = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0));
var late = Schedule(new(2026,5,1), new(2026,5,31), new(9,0,0));
var r = NextDueCalculator.Compute(new[]{late, early}, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(early.Id, r!.Schedule.Id);
}
}
```
- [ ] **Step 2: Run tests, confirm failure**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~NextDueCalculatorTests"`
Expected: compile error — `NextDueCalculator` does not exist.
- [ ] **Step 3: Implement `NextDueCalculator`**
```csharp
// src/ClaudeDo.Worker/Prime/NextDueCalculator.cs
namespace ClaudeDo.Worker.Prime;
public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately);
public static class NextDueCalculator
{
public static NextDue? Compute(
IEnumerable<PrimeScheduleDto> schedules,
DateTimeOffset now,
TimeSpan catchUp)
{
NextDue? best = null;
foreach (var s in schedules)
{
if (!s.Enabled) continue;
var due = ComputeFor(s, now, catchUp);
if (due is null) continue;
if (best is null || due.At < best.At) best = due;
}
return best;
}
private static NextDue? ComputeFor(PrimeScheduleDto s, DateTimeOffset now, TimeSpan catchUp)
{
if (s.EndDate < DateOnly.FromDateTime(now.LocalDateTime)) return null;
var todayLocal = DateOnly.FromDateTime(now.LocalDateTime);
var alreadyFiredToday = s.LastRunAt is { } last &&
DateOnly.FromDateTime(last.LocalDateTime) == todayLocal;
// Try today first
if (!alreadyFiredToday)
{
var startOrToday = s.StartDate > todayLocal ? s.StartDate : todayLocal;
if (startOrToday == todayLocal && IsEligibleDay(s, todayLocal))
{
var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset);
if (todayTarget >= now)
return new NextDue(s, todayTarget, false);
if (now <= todayTarget + catchUp)
return new NextDue(s, now, true);
// else fall through to tomorrow
}
}
// Find the next eligible day strictly after today
var d = todayLocal.AddDays(1);
if (s.StartDate > d) d = s.StartDate;
for (int i = 0; i < 8; i++) // at most a week ahead to find a workday
{
if (d > s.EndDate) return null;
if (IsEligibleDay(s, d))
return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false);
d = d.AddDays(1);
}
return null;
}
private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d)
{
if (d < s.StartDate || d > s.EndDate) return false;
if (!s.WorkdaysOnly) return true;
var dow = d.ToDateTime(TimeOnly.MinValue).DayOfWeek;
return dow != DayOfWeek.Saturday && dow != DayOfWeek.Sunday;
}
private static DateTimeOffset ToOffset(DateOnly day, TimeSpan time, TimeSpan offset) =>
new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset);
}
```
- [ ] **Step 4: Run tests, confirm pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~NextDueCalculatorTests"`
Expected: 8 passed.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Prime/NextDueCalculator.cs tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs
git commit -m "feat(worker): add NextDueCalculator with workday + catch-up logic"
```
---
## Task 7: Add `PrimeScheduler : BackgroundService` + integration tests
**Files:**
- Create: `src/ClaudeDo.Worker/Prime/PrimeScheduler.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`
This BackgroundService is thin: it loops, computes next due, awaits, and fires. Logic lives in `NextDueCalculator`; concrete IO lives in `IPrimeRunner`.
- [ ] **Step 1: Write failing scheduler tests**
```csharp
// tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
namespace ClaudeDo.Worker.Tests.Prime;
public class PrimeSchedulerTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
private sealed class FakeClock : IPrimeClock
{
public DateTimeOffset Now { get; set; }
}
private sealed class FakeRunner : IPrimeRunner
{
public List<Guid> FiredIds { get; } = new();
public Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto s, CancellationToken ct)
{
FiredIds.Add(s.Id);
return Task.FromResult(new PrimeRunOutcome(true, "ok"));
}
}
private sealed class FakeBroadcaster : IPrimeBroadcaster
{
public List<(Guid id, bool ok, string msg)> Calls { get; } = new();
public Task PrimeFiredAsync(Guid id, bool ok, string msg, DateTimeOffset firedAt)
{
Calls.Add((id, ok, msg));
return Task.CompletedTask;
}
}
[Fact]
public async Task Fires_Schedule_When_Within_CatchUp_On_Startup()
{
var id = Guid.NewGuid();
using (var ctx = _db.CreateContext())
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
StartDate = new DateOnly(2026, 5, 5),
EndDate = new DateOnly(2026, 5, 5),
TimeOfDay = new TimeSpan(7, 0, 0),
WorkdaysOnly = false,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
var clock = new FakeClock { Now = new DateTimeOffset(2026, 5, 5, 7, 10, 0, TimeSpan.FromHours(2)) };
var runner = new FakeRunner();
var broadcaster = new FakeBroadcaster();
var signal = new PrimeScheduleSignal();
var scheduler = new PrimeScheduler(
_db.CreateFactory(), runner, clock, signal, broadcaster,
PrimeSchedulerOptions.Default, NullLogger<PrimeScheduler>.Instance);
using var cts = new CancellationTokenSource();
var run = scheduler.StartAsync(cts.Token);
// Allow scheduler to fire
await WaitFor(() => runner.FiredIds.Count >= 1, TimeSpan.FromSeconds(3));
cts.Cancel();
await scheduler.StopAsync(CancellationToken.None);
Assert.Single(runner.FiredIds);
Assert.Equal(id, runner.FiredIds[0]);
Assert.Single(broadcaster.Calls);
Assert.True(broadcaster.Calls[0].ok);
using var read = _db.CreateContext();
var row = await new PrimeScheduleRepository(read).GetAsync(id);
Assert.NotNull(row!.LastRunAt);
}
[Fact]
public async Task Does_Not_Fire_Past_CatchUp_Window()
{
var id = Guid.NewGuid();
using (var ctx = _db.CreateContext())
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
StartDate = new DateOnly(2026, 5, 5),
EndDate = new DateOnly(2026, 5, 5),
TimeOfDay = new TimeSpan(7, 0, 0),
WorkdaysOnly = false,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
// 09:00 — 2h after target, well past 30min catch-up
var clock = new FakeClock { Now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)) };
var runner = new FakeRunner();
var signal = new PrimeScheduleSignal();
var scheduler = new PrimeScheduler(
_db.CreateFactory(), runner, clock, signal, new FakeBroadcaster(),
PrimeSchedulerOptions.Default, NullLogger<PrimeScheduler>.Instance);
using var cts = new CancellationTokenSource();
await scheduler.StartAsync(cts.Token);
await Task.Delay(200);
cts.Cancel();
await scheduler.StopAsync(CancellationToken.None);
Assert.Empty(runner.FiredIds);
}
[Fact]
public async Task Signal_Recomputes_Mid_Wait()
{
// Empty DB, scheduler waits on signal. Insert a due schedule and signal — fires.
var clock = new FakeClock { Now = new DateTimeOffset(2026, 5, 5, 7, 10, 0, TimeSpan.FromHours(2)) };
var runner = new FakeRunner();
var signal = new PrimeScheduleSignal();
var scheduler = new PrimeScheduler(
_db.CreateFactory(), runner, clock, signal, new FakeBroadcaster(),
PrimeSchedulerOptions.Default, NullLogger<PrimeScheduler>.Instance);
using var cts = new CancellationTokenSource();
await scheduler.StartAsync(cts.Token);
var id = Guid.NewGuid();
using (var ctx = _db.CreateContext())
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
StartDate = new DateOnly(2026, 5, 5),
EndDate = new DateOnly(2026, 5, 5),
TimeOfDay = new TimeSpan(7, 0, 0),
WorkdaysOnly = false,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
signal.Signal();
await WaitFor(() => runner.FiredIds.Count >= 1, TimeSpan.FromSeconds(3));
cts.Cancel();
await scheduler.StopAsync(CancellationToken.None);
Assert.Single(runner.FiredIds);
}
private static async Task WaitFor(Func<bool> cond, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!cond() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}
```
- [ ] **Step 2: Run tests, confirm failure**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PrimeSchedulerTests"`
Expected: compile error — `PrimeScheduler` and `IPrimeBroadcaster` do not exist.
- [ ] **Step 3: Add `IPrimeBroadcaster`**
```csharp
// src/ClaudeDo.Worker/Prime/IPrimeBroadcaster.cs
namespace ClaudeDo.Worker.Prime;
public interface IPrimeBroadcaster
{
Task PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt);
}
```
- [ ] **Step 4: Implement `PrimeScheduler`**
```csharp
// src/ClaudeDo.Worker/Prime/PrimeScheduler.cs
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
namespace ClaudeDo.Worker.Prime;
public sealed class PrimeScheduler : BackgroundService
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IPrimeRunner _runner;
private readonly IPrimeClock _clock;
private readonly IPrimeScheduleSignal _signal;
private readonly IPrimeBroadcaster _broadcaster;
private readonly PrimeSchedulerOptions _options;
private readonly ILogger<PrimeScheduler> _logger;
public PrimeScheduler(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IPrimeRunner runner,
IPrimeClock clock,
IPrimeScheduleSignal signal,
IPrimeBroadcaster broadcaster,
PrimeSchedulerOptions options,
ILogger<PrimeScheduler> logger)
{
_dbFactory = dbFactory;
_runner = runner;
_clock = clock;
_signal = signal;
_broadcaster = broadcaster;
_options = options;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await TickAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "PrimeScheduler tick failed; backing off");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
private async Task TickAsync(CancellationToken stoppingToken)
{
var schedules = await LoadAsync(stoppingToken);
var now = _clock.Now;
var due = NextDueCalculator.Compute(schedules, now, _options.CatchUpWindow);
var signalToken = _signal.CurrentToken;
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, signalToken);
if (due is null)
{
try { await Task.Delay(TimeSpan.FromHours(1), linked.Token); }
catch (OperationCanceledException) { /* signal or shutdown */ }
return;
}
var delay = due.FireImmediately ? TimeSpan.Zero : due.At - now;
if (delay > TimeSpan.Zero)
{
try { await Task.Delay(delay, linked.Token); }
catch (OperationCanceledException)
{
if (signalToken.IsCancellationRequested) return; // recompute
throw;
}
}
await FireAsync(due.Schedule, stoppingToken);
}
private async Task<IReadOnlyList<PrimeScheduleDto>> LoadAsync(CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var rows = await new PrimeScheduleRepository(ctx).ListAsync(ct);
return rows.Select(ToDto).ToList();
}
private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) =>
new(e.Id, e.StartDate, e.EndDate, e.TimeOfDay, e.WorkdaysOnly, e.Enabled, e.LastRunAt, e.PromptOverride);
private async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
{
var firedAt = _clock.Now;
var outcome = await _runner.FireAsync(schedule, ct);
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
await new PrimeScheduleRepository(ctx).UpdateLastRunAsync(schedule.Id, firedAt, ct);
await _broadcaster.PrimeFiredAsync(schedule.Id, outcome.Success, outcome.Message, firedAt);
if (outcome.Success)
_logger.LogInformation("Prime fired {Id} at {When}", schedule.Id, firedAt);
else
_logger.LogWarning("Prime failed {Id}: {Msg}", schedule.Id, outcome.Message);
}
}
```
- [ ] **Step 5: Run tests, confirm pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PrimeSchedulerTests"`
Expected: 3 passed. (If `Signal_Recomputes_Mid_Wait` is flaky on slow CI, increase the `WaitFor` timeout.)
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Prime/IPrimeBroadcaster.cs src/ClaudeDo.Worker/Prime/PrimeScheduler.cs tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs
git commit -m "feat(worker): add PrimeScheduler hosted service"
```
---
## Task 8: Wire `HubBroadcaster` to publish `PrimeFired`
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
- [ ] **Step 1: Add the broadcast method + implement `IPrimeBroadcaster`**
In `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`, add this method alongside the existing ones, and make the class implement `IPrimeBroadcaster`:
```csharp
// add to using's:
using ClaudeDo.Worker.Prime;
```
Change the class declaration:
```csharp
public sealed class HubBroadcaster : IPrimeBroadcaster
```
Append:
```csharp
public Task PrimeFired(Guid scheduleId, bool success, string message, DateTimeOffset firedAt) =>
_hub.Clients.All.SendAsync("PrimeFired", scheduleId, success, message, firedAt);
Task IPrimeBroadcaster.PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt) =>
PrimeFired(scheduleId, success, message, firedAt);
```
- [ ] **Step 2: Build to confirm**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs
git commit -m "feat(worker): broadcast PrimeFired SignalR event"
```
---
## Task 9: Add hub methods (`ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`)
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- [ ] **Step 1: Add `using` and inject `IPrimeScheduleSignal`**
Add to imports:
```csharp
using ClaudeDo.Worker.Prime;
```
Add a private field and constructor parameter for `IPrimeScheduleSignal _primeSignal`. Pattern matches the existing constructor — append the parameter at the end and assign it.
- [ ] **Step 2: Add the three hub methods**
Append below `UpdateAppSettings`:
```csharp
public async Task<List<PrimeScheduleDto>> ListPrimeSchedules()
{
using var ctx = _dbFactory.CreateDbContext();
var rows = await new PrimeScheduleRepository(ctx).ListAsync();
return rows.Select(e => new PrimeScheduleDto(
e.Id, e.StartDate, e.EndDate, e.TimeOfDay,
e.WorkdaysOnly, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList();
}
public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto)
{
using var ctx = _dbFactory.CreateDbContext();
var repo = new PrimeScheduleRepository(ctx);
var existing = await repo.GetAsync(dto.Id);
var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity
{
Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id,
StartDate = dto.StartDate,
EndDate = dto.EndDate,
TimeOfDay = dto.TimeOfDay,
WorkdaysOnly = dto.WorkdaysOnly,
Enabled = dto.Enabled,
PromptOverride = dto.PromptOverride,
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
LastRunAt = existing?.LastRunAt,
};
await repo.UpsertAsync(entity);
_primeSignal.Signal();
return new PrimeScheduleDto(entity.Id, entity.StartDate, entity.EndDate, entity.TimeOfDay,
entity.WorkdaysOnly, entity.Enabled, entity.LastRunAt, entity.PromptOverride);
}
public async Task DeletePrimeSchedule(Guid id)
{
using var ctx = _dbFactory.CreateDbContext();
await new PrimeScheduleRepository(ctx).DeleteAsync(id);
_primeSignal.Signal();
}
```
- [ ] **Step 3: Add a using for the repository**
```csharp
using ClaudeDo.Data.Repositories;
```
(Already imported earlier in the file — verify.)
- [ ] **Step 4: Build worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(worker): add Prime schedule hub methods"
```
---
## Task 10: Register Prime services in `Program.cs`
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs`
- [ ] **Step 1: Add the registrations**
Just before `builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());` (around line 79), add:
```csharp
// Prime Claude
builder.Services.AddSingleton<IPrimeClock, PrimeClock>();
builder.Services.AddSingleton<PrimeScheduleSignal>();
builder.Services.AddSingleton<IPrimeScheduleSignal>(sp => sp.GetRequiredService<PrimeScheduleSignal>());
builder.Services.AddSingleton<IPrimeRunner, PrimeRunner>();
builder.Services.AddSingleton(PrimeSchedulerOptions.Default);
builder.Services.AddSingleton<IPrimeBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
builder.Services.AddHostedService<PrimeScheduler>();
```
Add `using ClaudeDo.Worker.Prime;` to the imports if not already present.
- [ ] **Step 2: Build to confirm wiring**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds, no DI resolution warnings.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): register Prime services in DI"
```
---
## Task 11: Mirror `PrimeScheduleDto` + client methods in UI `WorkerClient`
**Files:**
- Create: `src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
The UI cannot reference `ClaudeDo.Worker`. Mirror the DTO shape used over the wire.
- [ ] **Step 1: Create the UI-side DTO**
```csharp
// src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs
namespace ClaudeDo.Ui.Services;
public sealed record PrimeScheduleDto(
Guid Id,
DateOnly StartDate,
DateOnly EndDate,
TimeSpan TimeOfDay,
bool WorkdaysOnly,
bool Enabled,
DateTimeOffset? LastRunAt,
string? PromptOverride);
public sealed record PrimeFiredEvent(
Guid ScheduleId,
bool Success,
string Message,
DateTimeOffset FiredAt);
```
- [ ] **Step 2: Add invoke methods + event handler in `WorkerClient`**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, declare a public event near the existing event declarations:
```csharp
public event Action<PrimeFiredEvent>? PrimeFired;
```
In the constructor, after the existing `_hub.On<...>` registrations, add:
```csharp
_hub.On<Guid, bool, string, DateTimeOffset>("PrimeFired", (id, ok, msg, when) =>
{
Dispatcher.UIThread.Post(() => PrimeFired?.Invoke(new PrimeFiredEvent(id, ok, msg, when)));
});
```
Append public methods alongside the existing ones (e.g. after `UpdateAppSettingsAsync`):
```csharp
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
{
try { return await _hub.InvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules"); }
catch { return new List<PrimeScheduleDto>(); }
}
public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
{
try { return await _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto); }
catch { return null; }
}
public async Task DeletePrimeScheduleAsync(Guid id)
{
try { await _hub.InvokeAsync("DeletePrimeSchedule", id); }
catch { /* offline */ }
}
```
- [ ] **Step 3: Build UI**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): add Prime schedule client + PrimeFired event"
```
---
## Task 12: Extract per-tab settings VMs (preparatory refactor)
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs`
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs`
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`
This task moves logic only — no behavior changes. The View still binds to the same property paths via `Binding General.DefaultModel` once the View is updated in Task 14.
- [ ] **Step 1: Create `GeneralSettingsTabViewModel`**
```csharp
// src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
{
[ObservableProperty] private string _defaultClaudeInstructions = "";
[ObservableProperty] private string _defaultModel = "sonnet";
[ObservableProperty] private int _defaultMaxTurns = 100;
[ObservableProperty] private string _defaultPermissionMode = "auto";
public IReadOnlyList<string> Models { get; } = new[] { "opus", "sonnet", "haiku" };
public IReadOnlyList<string> PermissionModes { get; } = new[]
{ "auto", "bypassPermissions", "acceptEdits", "plan", "default" };
public string? Validate()
{
if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200)
return "Max turns must be between 1 and 200.";
return null;
}
}
```
- [ ] **Step 2: Create `WorktreesSettingsTabViewModel`**
```csharp
// src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs
using System.IO;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
[ObservableProperty] private string _worktreeStrategy = "sibling";
[ObservableProperty] private string? _centralWorktreeRoot;
[ObservableProperty] private bool _worktreeAutoCleanupEnabled;
[ObservableProperty] private int _worktreeAutoCleanupDays = 7;
[ObservableProperty] private bool _showResetConfirm;
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy;
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
public WorktreesSettingsTabViewModel(WorkerClient worker) => _worker = worker;
public string? Validate()
{
if (WorktreeAutoCleanupEnabled && (WorktreeAutoCleanupDays < 1 || WorktreeAutoCleanupDays > 365))
return "Cleanup days must be between 1 and 365.";
if (WorktreeStrategy == "central")
{
if (string.IsNullOrWhiteSpace(CentralWorktreeRoot))
return "Central worktree root is required for Central strategy.";
if (!Directory.Exists(CentralWorktreeRoot))
return $"Directory not found: {CentralWorktreeRoot}";
}
return null;
}
[RelayCommand]
private async Task CleanupWorktrees()
{
IsBusy = true; StatusMessage = "";
try
{
var r = await _worker.CleanupFinishedWorktreesAsync();
StatusMessage = r is null ? "Worker offline." : $"Removed {r.Removed} worktree(s).";
}
finally { IsBusy = false; }
}
[RelayCommand] private void RequestResetConfirm() => ShowResetConfirm = true;
[RelayCommand] private void CancelResetConfirm() => ShowResetConfirm = false;
[RelayCommand]
private async Task ConfirmResetAll()
{
ShowResetConfirm = false; IsBusy = true; StatusMessage = "";
try
{
var r = await _worker.ResetAllWorktreesAsync();
if (r is null) StatusMessage = "Worker offline.";
else if (r.Blocked) StatusMessage = $"Cannot force-remove: {r.RunningTasks} task(s) still running. Cancel them first.";
else StatusMessage = $"Removed {r.Removed} worktree(s) from {r.TasksAffected} task(s).";
}
finally { IsBusy = false; }
}
}
```
- [ ] **Step 3: Create `FilesSettingsTabViewModel`**
```csharp
// src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs
using System.Diagnostics;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class FilesSettingsTabViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy;
public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System);
public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning);
public string AgentPromptPath { get; } = PromptFiles.PathFor(PromptKind.Agent);
public FilesSettingsTabViewModel(WorkerClient worker) => _worker = worker;
[RelayCommand]
private async Task RestoreDefaultAgents()
{
IsBusy = true; StatusMessage = "";
try
{
var r = await _worker.RestoreDefaultAgentsAsync();
if (r is null) StatusMessage = "Worker offline.";
else if (r.Copied == 0 && r.Skipped == 0) StatusMessage = "No default agents bundled.";
else if (r.Copied == 0) StatusMessage = "All default agents already present.";
else StatusMessage = $"Restored {r.Copied} default agent(s).";
await _worker.RefreshAgentsAsync();
}
catch (Exception ex) { StatusMessage = $"Restore failed: {ex.Message}"; }
finally { IsBusy = false; }
}
[RelayCommand]
private void OpenPrompt(string? kindName)
{
if (!Enum.TryParse<PromptKind>(kindName, ignoreCase: true, out var kind)) return;
try
{
PromptFiles.EnsureExists(kind);
var path = PromptFiles.PathFor(kind);
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex) { StatusMessage = $"Open failed: {ex.Message}"; }
}
}
```
- [ ] **Step 4: Slim down `SettingsModalViewModel` to a coordinator**
Replace the body of `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`:
```csharp
using System.Diagnostics;
using System.IO;
using System.Reflection;
using ClaudeDo.Data;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class SettingsModalViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
public GeneralSettingsTabViewModel General { get; }
public WorktreesSettingsTabViewModel Worktrees { get; }
public FilesSettingsTabViewModel Files { get; }
public PrimeClaudeTabViewModel Prime { get; } // added in Task 13
[ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _statusMessage = "";
public Action? CloseAction { get; set; }
public SettingsModalViewModel(WorkerClient worker, PrimeClaudeTabViewModel prime)
{
_worker = worker;
General = new GeneralSettingsTabViewModel();
Worktrees = new WorktreesSettingsTabViewModel(worker);
Files = new FilesSettingsTabViewModel(worker);
Prime = prime;
}
public async Task LoadAsync()
{
IsBusy = true;
try
{
var dto = await _worker.GetAppSettingsAsync();
if (dto is not null)
{
General.DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "";
General.DefaultModel = dto.DefaultModel ?? "sonnet";
General.DefaultMaxTurns = dto.DefaultMaxTurns;
General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto";
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
Worktrees.WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays;
}
else StatusMessage = "Worker offline — settings read-only.";
await Prime.LoadAsync();
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task Save()
{
var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate();
if (err is not null) { ValidationError = err; return; }
ValidationError = "";
IsBusy = true;
try
{
var dto = new AppSettingsDto(
General.DefaultClaudeInstructions ?? "",
General.DefaultModel ?? "sonnet",
General.DefaultMaxTurns,
General.DefaultPermissionMode ?? "auto",
Worktrees.WorktreeStrategy ?? "sibling",
string.IsNullOrWhiteSpace(Worktrees.CentralWorktreeRoot) ? null : Worktrees.CentralWorktreeRoot,
Worktrees.WorktreeAutoCleanupEnabled,
Worktrees.WorktreeAutoCleanupDays);
await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
CloseAction?.Invoke();
}
catch (Exception ex) { StatusMessage = $"Save failed: {ex.Message}"; }
finally { IsBusy = false; }
}
[RelayCommand] private void Cancel() => CloseAction?.Invoke();
}
```
- [ ] **Step 5: Build UI to verify shape (it will fail until Task 13 supplies `PrimeClaudeTabViewModel`)**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: compile error pointing at `PrimeClaudeTabViewModel`. This is intentional — Task 13 fills it in.
- [ ] **Step 6: Do not commit yet**
Wait for Task 13 to make the UI compile, then commit Tasks 12 + 13 together.
---
## Task 13: Add `PrimeClaudeTabViewModel` + row VM (TDD)
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs`
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs`
- Create: `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`
- [ ] **Step 1: Write failing tests**
```csharp
// tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class PrimeClaudeTabViewModelTests
{
private sealed class FakeApi : IPrimeScheduleApi
{
public List<PrimeScheduleDto> Stored { get; } = new();
public List<PrimeScheduleDto> Upserts { get; } = new();
public List<Guid> Deletes { get; } = new();
public Task<List<PrimeScheduleDto>> ListAsync() => Task.FromResult(Stored.ToList());
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto)
{
Upserts.Add(dto);
return Task.FromResult<PrimeScheduleDto?>(dto);
}
public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; }
}
[Fact]
public async Task Load_Populates_Rows()
{
var api = new FakeApi();
api.Stored.Add(new PrimeScheduleDto(
Guid.NewGuid(), new DateOnly(2026,5,1), new DateOnly(2026,5,31),
new TimeSpan(7,0,0), true, true, null, null));
var vm = new PrimeClaudeTabViewModel(api);
await vm.LoadAsync();
Assert.Single(vm.Rows);
}
[Fact]
public void AddSchedule_Appends_Row_With_Defaults()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
Assert.Single(vm.Rows);
Assert.True(vm.Rows[0].Enabled);
Assert.True(vm.Rows[0].WorkdaysOnly);
Assert.Equal(new TimeSpan(7,0,0), vm.Rows[0].TimeOfDay);
}
[Fact]
public async Task Save_Diffs_New_And_Removed_Rows()
{
var api = new FakeApi();
var keptId = Guid.NewGuid();
var deletedId = Guid.NewGuid();
api.Stored.Add(new PrimeScheduleDto(keptId, new(2026,5,1), new(2026,5,31), new(7,0,0), true, true, null, null));
api.Stored.Add(new PrimeScheduleDto(deletedId, new(2026,5,1), new(2026,5,31), new(8,0,0), true, true, null, null));
var vm = new PrimeClaudeTabViewModel(api);
await vm.LoadAsync();
vm.RemoveScheduleCommand.Execute(vm.Rows.Single(r => r.Id == deletedId));
vm.AddScheduleCommand.Execute(null);
await vm.SaveAsync();
Assert.Contains(deletedId, api.Deletes);
// 2 upserts: the kept (existing) row + the newly added row
Assert.Equal(2, api.Upserts.Count);
}
[Fact]
public void Validate_Reports_StartAfterEnd()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
vm.Rows[0].StartDate = new DateOnly(2026, 6, 1);
vm.Rows[0].EndDate = new DateOnly(2026, 5, 1);
Assert.NotNull(vm.Validate());
}
}
```
- [ ] **Step 2: Run tests, confirm failure (compile error)**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter "FullyQualifiedName~PrimeClaudeTabViewModelTests"`
Expected: compile error.
- [ ] **Step 3: Implement the row VM**
```csharp
// src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
{
public Guid Id { get; }
public bool IsExisting { get; }
[ObservableProperty] private bool _enabled;
[ObservableProperty] private DateOnly _startDate;
[ObservableProperty] private DateOnly _endDate;
[ObservableProperty] private TimeSpan _timeOfDay;
[ObservableProperty] private bool _workdaysOnly;
[ObservableProperty] private DateTimeOffset? _lastRunAt;
public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—";
partial void OnLastRunAtChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastRunLabel));
public PrimeScheduleRowViewModel(PrimeScheduleDto dto, bool isExisting)
{
Id = dto.Id;
IsExisting = isExisting;
Enabled = dto.Enabled;
StartDate = dto.StartDate;
EndDate = dto.EndDate;
TimeOfDay = dto.TimeOfDay;
WorkdaysOnly = dto.WorkdaysOnly;
LastRunAt = dto.LastRunAt;
}
public PrimeScheduleDto ToDto() =>
new(Id, StartDate, EndDate, TimeOfDay, WorkdaysOnly, Enabled, LastRunAt, null);
}
```
- [ ] **Step 4: Add `IPrimeScheduleApi` and adapter on `WorkerClient`**
Add abstraction (avoids coupling tests to SignalR):
```csharp
// src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs
namespace ClaudeDo.Ui.Services;
public interface IPrimeScheduleApi
{
Task<List<PrimeScheduleDto>> ListAsync();
Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto);
Task DeleteAsync(Guid id);
}
public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi
{
private readonly WorkerClient _client;
public WorkerPrimeScheduleApi(WorkerClient client) => _client = client;
public Task<List<PrimeScheduleDto>> ListAsync() => _client.GetPrimeSchedulesAsync();
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto) => _client.UpsertPrimeScheduleAsync(dto);
public Task DeleteAsync(Guid id) => _client.DeletePrimeScheduleAsync(id);
}
```
- [ ] **Step 5: Implement the tab VM**
```csharp
// src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs
using System.Collections.ObjectModel;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class PrimeClaudeTabViewModel : ViewModelBase
{
private readonly IPrimeScheduleApi _api;
private readonly HashSet<Guid> _initialIds = new();
public ObservableCollection<PrimeScheduleRowViewModel> Rows { get; } = new();
public PrimeClaudeTabViewModel(IPrimeScheduleApi api) => _api = api;
public async Task LoadAsync()
{
Rows.Clear();
_initialIds.Clear();
var list = await _api.ListAsync();
foreach (var dto in list)
{
Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: true));
_initialIds.Add(dto.Id);
}
}
public string? Validate()
{
foreach (var r in Rows)
{
if (r.StartDate > r.EndDate)
return $"Schedule {r.TimeOfDay:hh\\:mm}: start date is after end date.";
if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1))
return "Time must be between 00:00 and 23:59.";
}
return null;
}
public async Task SaveAsync()
{
var keepIds = Rows.Select(r => r.Id).ToHashSet();
foreach (var removed in _initialIds.Where(id => !keepIds.Contains(id)).ToList())
await _api.DeleteAsync(removed);
foreach (var r in Rows)
await _api.UpsertAsync(r.ToDto());
_initialIds.Clear();
foreach (var id in keepIds) _initialIds.Add(id);
}
[RelayCommand]
private void AddSchedule()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var dto = new PrimeScheduleDto(
Id: Guid.NewGuid(),
StartDate: today,
EndDate: today.AddDays(30),
TimeOfDay: new TimeSpan(7, 0, 0),
WorkdaysOnly: true,
Enabled: true,
LastRunAt: null,
PromptOverride: null);
Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: false));
}
[RelayCommand]
private void RemoveSchedule(PrimeScheduleRowViewModel? row)
{
if (row is null) return;
Rows.Remove(row);
}
public void ApplyFiredEvent(PrimeFiredEvent evt)
{
var row = Rows.FirstOrDefault(r => r.Id == evt.ScheduleId);
if (row is null) return;
if (evt.Success) row.LastRunAt = evt.FiredAt;
}
}
```
- [ ] **Step 6: Run tests, confirm pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter "FullyQualifiedName~PrimeClaudeTabViewModelTests"`
Expected: 4 passed.
- [ ] **Step 7: Build UI**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds (this also clears the Task 12 break).
- [ ] **Step 8: Commit Tasks 12 + 13 together**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/Settings/ src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs
git commit -m "feat(ui): split SettingsModalViewModel into per-tab VMs + add PrimeClaudeTabViewModel"
```
---
## Task 14: Refactor `SettingsModalView.axaml` to TabControl + add Prime Claude tab
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
This is a substantial XAML rewrite. The body changes from a `ScrollViewer` of `StackPanels` to a `TabControl` with four `TabItem`s. Bindings change from `{Binding DefaultModel}` to `{Binding General.DefaultModel}`, etc.
- [ ] **Step 1: Replace the body `ScrollViewer` (lines 83283 in current file) with a `TabControl`**
Replace the entire `<ScrollViewer Grid.Row="1" ...> ... </ScrollViewer>` block with the structure below. Keep the Title bar (Grid.Row=0) and Footer (Grid.Row=2) unchanged.
```xml
<TabControl Grid.Row="1" Padding="20,12" TabStripPlacement="Top">
<!-- GENERAL -->
<TabItem Header="General">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Default instructions"/>
<TextBox AcceptsReturn="True" TextWrapping="Wrap" Height="110"
Watermark="Baseline instructions applied to every task"
Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="Model"/>
<ComboBox ItemsSource="{Binding General.Models}"
SelectedItem="{Binding General.DefaultModel, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Classes="field-label" Text="Max turns"/>
<NumericUpDown Value="{Binding General.DefaultMaxTurns, Mode=TwoWay}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"/>
</StackPanel>
<StackPanel Grid.Column="4" Spacing="4">
<TextBlock Classes="field-label" Text="Permission"/>
<ComboBox ItemsSource="{Binding General.PermissionModes}"
SelectedItem="{Binding General.DefaultPermissionMode, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- WORKTREES -->
<TabItem Header="Worktrees">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<Grid ColumnDefinitions="*,12,2*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="Strategy"/>
<ComboBox ItemsSource="{Binding Worktrees.WorktreeStrategies}"
SelectedItem="{Binding Worktrees.WorktreeStrategy, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Classes="field-label" Text="Central worktree root"/>
<TextBox Text="{Binding Worktrees.CentralWorktreeRoot, Mode=TwoWay}"
Watermark="e.g. C:\worktrees"/>
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="8">
<CheckBox IsChecked="{Binding Worktrees.WorktreeAutoCleanupEnabled, Mode=TwoWay}"
Content="Auto-cleanup finished worktrees after"
VerticalAlignment="Center"/>
<NumericUpDown Value="{Binding Worktrees.WorktreeAutoCleanupDays, Mode=TwoWay}"
Width="130" Minimum="1" Maximum="365" Increment="1" FormatString="0"
IsEnabled="{Binding Worktrees.WorktreeAutoCleanupEnabled}"/>
<TextBlock Text="days" VerticalAlignment="Center"
Foreground="{DynamicResource TextDimBrush}"/>
</StackPanel>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,4,0,0"/>
<StackPanel Spacing="8">
<Button Content="Cleanup finished worktrees"
Command="{Binding Worktrees.CleanupWorktreesCommand}"
HorizontalAlignment="Left"/>
<StackPanel>
<Button Content="Force-remove all worktrees" Classes="danger"
Command="{Binding Worktrees.RequestResetConfirmCommand}"
HorizontalAlignment="Left"
IsVisible="{Binding !Worktrees.ShowResetConfirm}"/>
<Border BorderBrush="{DynamicResource BloodBrush}" BorderThickness="1"
CornerRadius="6" Padding="12,10"
IsVisible="{Binding Worktrees.ShowResetConfirm}">
<StackPanel Spacing="8">
<TextBlock Text="Remove ALL worktrees? Uncommitted work will be lost."
Foreground="{DynamicResource TextBrush}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Cancel" Command="{Binding Worktrees.CancelResetConfirmCommand}"/>
<Button Content="Remove All" Classes="danger"
Command="{Binding Worktrees.ConfirmResetAllCommand}"/>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
<TextBlock Text="{Binding Worktrees.StatusMessage}"
Foreground="{DynamicResource TextDimBrush}" FontSize="11"
IsVisible="{Binding Worktrees.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- FILES -->
<TabItem Header="Files">
<ScrollViewer>
<StackPanel Spacing="14" Margin="0,8,0,0">
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="AGENTS"/>
<TextBlock Text="Restore bundled default agents. Existing files are not overwritten."
FontSize="11" TextWrapping="Wrap"
Foreground="{DynamicResource TextDimBrush}"/>
<Button Content="Restore default agents"
Command="{Binding Files.RestoreDefaultAgentsCommand}"
IsEnabled="{Binding !Files.IsBusy}"
HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="PROMPTS"/>
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="System" VerticalAlignment="Center"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono" Text="{Binding Files.SystemPromptPath}" VerticalAlignment="Center"/>
<Button Grid.Row="0" Grid.Column="2" Content="Open in editor"
Command="{Binding Files.OpenPromptCommand}" CommandParameter="System"/>
<TextBlock Grid.Row="1" Grid.Column="0" Classes="field-label" Text="Planning" VerticalAlignment="Center"/>
<TextBlock Grid.Row="1" Grid.Column="1" Classes="path-mono" Text="{Binding Files.PlanningPromptPath}" VerticalAlignment="Center"/>
<Button Grid.Row="1" Grid.Column="2" Content="Open in editor"
Command="{Binding Files.OpenPromptCommand}" CommandParameter="Planning"/>
<TextBlock Grid.Row="2" Grid.Column="0" Classes="field-label" Text="Agent" VerticalAlignment="Center"/>
<TextBlock Grid.Row="2" Grid.Column="1" Classes="path-mono" Text="{Binding Files.AgentPromptPath}" VerticalAlignment="Center"/>
<Button Grid.Row="2" Grid.Column="2" Content="Open in editor"
Command="{Binding Files.OpenPromptCommand}" CommandParameter="Agent"/>
</Grid>
</StackPanel>
<TextBlock Text="{Binding Files.StatusMessage}"
Foreground="{DynamicResource TextDimBrush}" FontSize="11"
IsVisible="{Binding Files.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- PRIME CLAUDE -->
<TabItem Header="Prime Claude">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<TextBlock TextWrapping="Wrap" FontSize="11"
Foreground="{DynamicResource TextDimBrush}"
Text="Prime your Claude usage window each morning by firing a single non-interactive `ping` call at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately."/>
<ItemsControl ItemsSource="{Binding Prime.Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="settings:PrimeScheduleRowViewModel">
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
Background="{DynamicResource DeepBrush}">
<Grid ColumnDefinitions="Auto,*,*,Auto,Auto,Auto,Auto" ColumnSpacing="8">
<CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/>
<CalendarDatePicker Grid.Column="1" SelectedDate="{Binding StartDate, Mode=TwoWay, Converter={StaticResource DateOnlyToDateTime}}"
VerticalAlignment="Center"/>
<CalendarDatePicker Grid.Column="2" SelectedDate="{Binding EndDate, Mode=TwoWay, Converter={StaticResource DateOnlyToDateTime}}"
VerticalAlignment="Center"/>
<TextBox Grid.Column="3" Width="64" Text="{Binding TimeOfDay, Mode=TwoWay, Converter={StaticResource TimeSpanToHhmm}}"
VerticalAlignment="Center"/>
<CheckBox Grid.Column="4" Content="MonFri" IsChecked="{Binding WorkdaysOnly, Mode=TwoWay}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="5" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
Foreground="{DynamicResource TextDimBrush}" FontSize="11"
MinWidth="80"/>
<Button Grid.Column="6" Content="✕"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="+ Add schedule" Command="{Binding Prime.AddScheduleCommand}" HorizontalAlignment="Left"/>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
```
- [ ] **Step 2: Add the new XAML namespace + converters at the root**
In the `<Window ...>` opening tag add:
```xml
xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings"
xmlns:conv="using:ClaudeDo.Ui.Converters"
```
In the `<Window.Resources>` block (add the block if absent, just before `<Window.Styles>`):
```xml
<Window.Resources>
<conv:DateOnlyToDateTimeConverter x:Key="DateOnlyToDateTime"/>
<conv:TimeSpanToHhmmConverter x:Key="TimeSpanToHhmm"/>
</Window.Resources>
```
- [ ] **Step 3: Move the validation/status footer strip below the TabControl**
Inside `Grid.Row="1"` of the outer Grid, the `TabControl` is the only child. Move the `<TextBlock Text="{Binding ValidationError}"...>` and the modal-level `StatusMessage` `TextBlock` so they sit between the TabControl and the existing Grid.Row=2 footer. Easiest implementation: change `Grid.Row="1"` to host a `DockPanel` with the warnings docked at bottom and the TabControl filling the rest:
```xml
<DockPanel Grid.Row="1">
<StackPanel DockPanel.Dock="Bottom" Margin="20,0,20,8" Spacing="2">
<TextBlock Text="{Binding ValidationError}" Foreground="{DynamicResource BloodBrush}" FontSize="11"
IsVisible="{Binding ValidationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding StatusMessage}" Foreground="{DynamicResource TextDimBrush}" FontSize="11"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<!-- TabControl from Step 1 goes here, without Grid.Row attribute -->
</DockPanel>
```
- [ ] **Step 4: Build UI**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build fails because the converters from Task 15 don't exist yet. Move on to Task 15.
- [ ] **Step 5: Do not commit yet** — combine with Task 15 + 16 + 17.
---
## Task 15: Add `DateOnly`/`TimeSpan` value converters
**Files:**
- Create: `src/ClaudeDo.Ui/Converters/DateOnlyToDateTimeConverter.cs`
- Create: `src/ClaudeDo.Ui/Converters/TimeSpanToHhmmConverter.cs`
Avalonia 12's `CalendarDatePicker` binds `DateTime?`, not `DateOnly`. The `TextBox` for time-of-day formats as `HH:mm`.
- [ ] **Step 1: DateOnly ↔ DateTime converter**
```csharp
// src/ClaudeDo.Ui/Converters/DateOnlyToDateTimeConverter.cs
using System.Globalization;
using Avalonia.Data.Converters;
namespace ClaudeDo.Ui.Converters;
public sealed class DateOnlyToDateTimeConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is DateOnly d)
return d.ToDateTime(TimeOnly.MinValue);
return null;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is DateTime dt)
return DateOnly.FromDateTime(dt);
if (value is DateTimeOffset dto)
return DateOnly.FromDateTime(dto.LocalDateTime);
return DateOnly.FromDateTime(DateTime.Today);
}
}
```
- [ ] **Step 2: TimeSpan ↔ "HH:mm" converter**
```csharp
// src/ClaudeDo.Ui/Converters/TimeSpanToHhmmConverter.cs
using System.Globalization;
using Avalonia.Data.Converters;
namespace ClaudeDo.Ui.Converters;
public sealed class TimeSpanToHhmmConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is TimeSpan t ? $"{t.Hours:00}:{t.Minutes:00}" : "07:00";
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string s) return new TimeSpan(7, 0, 0);
var parts = s.Split(':');
if (parts.Length == 2 &&
int.TryParse(parts[0], out var h) && h is >= 0 and <= 23 &&
int.TryParse(parts[1], out var m) && m is >= 0 and <= 59)
return new TimeSpan(h, m, 0);
return new TimeSpan(7, 0, 0);
}
}
```
- [ ] **Step 3: Build UI**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 4: Do not commit yet** — combine with Task 14 + 16 + 17.
---
## Task 16: Add About modal + Help menu wiring
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/AboutModalViewModel.cs`
- Create: `src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml`
- Create: `src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml.cs`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` (or wherever the Help menu commands live)
- [ ] **Step 1: Create the About VM (mirrors today's About section content)**
```csharp
// src/ClaudeDo.Ui/ViewModels/Modals/AboutModalViewModel.cs
using System.Diagnostics;
using System.IO;
using System.Reflection;
using ClaudeDo.Data;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class AboutModalViewModel : ViewModelBase
{
public string AppVersion { get; } =
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
public string DataFolderPath { get; } = Paths.AppDataRoot();
public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs");
public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
public Action? CloseAction { get; set; }
[RelayCommand] private void Close() => CloseAction?.Invoke();
[RelayCommand]
private void OpenPath(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return;
try
{
var target = File.Exists(path) ? path : (Directory.Exists(path) ? path : null);
if (target is null) return;
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{target}\"") { UseShellExecute = true });
}
catch { /* ignore */ }
}
}
```
- [ ] **Step 2: Create the View**
```xml
<!-- src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml -->
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.AboutModalView"
x:DataType="vm:AboutModalViewModel"
Title="About ClaudeDo"
Width="480" Height="280"
SystemDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1">
<Grid RowDefinitions="36,*,52">
<Border Grid.Row="0" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="ABOUT" 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 CloseCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" Padding="20,16">
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="10">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Version" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AppVersion}" FontFamily="{DynamicResource MonoFont}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Data" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding DataFolderPath}" FontFamily="{DynamicResource MonoFont}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<Button Grid.Row="1" Grid.Column="2" Content="Open" Command="{Binding OpenPathCommand}" CommandParameter="{Binding DataFolderPath}"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Logs" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding LogsFolderPath}" FontFamily="{DynamicResource MonoFont}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<Button Grid.Row="2" Grid.Column="2" Content="Open" Command="{Binding OpenPathCommand}" CommandParameter="{Binding LogsFolderPath}"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Config" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding WorkerConfigPath}" FontFamily="{DynamicResource MonoFont}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<Button Grid.Row="3" Grid.Column="2" Content="Open" Command="{Binding OpenPathCommand}" CommandParameter="{Binding WorkerConfigPath}"/>
</Grid>
</ScrollViewer>
<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="Close" Command="{Binding CloseCommand}" MinWidth="90"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>
```
- [ ] **Step 3: Code-behind for the About view**
```csharp
// src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml.cs
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace ClaudeDo.Ui.Views.Modals;
public partial class AboutModalView : Window
{
public AboutModalView() => InitializeComponent();
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
}
```
- [ ] **Step 4: Wire Help menu**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, locate the existing `<MenuItem Header="Help">` block and add a new child menu item:
```xml
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
```
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` (the VM bound as `MainWindow`'s DataContext root), add:
```csharp
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
[RelayCommand]
private async Task OpenAbout()
{
var vm = new AboutModalViewModel();
if (ShowAboutModal is not null) await ShowAboutModal(vm);
}
```
(If the existing Help menu commands like `CheckForUpdatesCommand` live on a different VM, follow the same wiring pattern there.)
In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, where other modals are wired (search for `ShowSettingsModal` or similar), add:
```csharp
viewModel.ShowAboutModal = async vm =>
{
var dlg = new AboutModalView { DataContext = vm };
var tcs = new TaskCompletionSource<bool>();
vm.CloseAction = () => { dlg.Close(); tcs.TrySetResult(true); };
await dlg.ShowDialog(this);
};
```
(Pattern matches the existing settings modal wiring — open `MainWindow.axaml.cs` and copy the `ShowSettingsModal` setup as a template.)
- [ ] **Step 5: Build UI**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 6: Do not commit yet** — combine with Task 14 + 15 + 17.
---
## Task 17: Footer notification — `StatusBarViewModel.PrimeStatus`
**Files:**
- Modify: existing status bar VM (find via `grep -rn "PrimeStatus\|ConnectionText\|WorkerLogText"` — the host is likely `IslandsShellViewModel`)
- Modify: the matching status bar XAML (search for `IsWorkerLogVisible` to locate it)
The host is `IslandsShellViewModel` per the existing `WorkerLogText` / `IsWorkerLogVisible` properties.
- [ ] **Step 1: Add fields + subscription on `IslandsShellViewModel`**
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`:
```csharp
[ObservableProperty] private string? _primeStatus;
private readonly System.Timers.Timer _primeStatusTimer = new(5_000) { AutoReset = false };
```
In the constructor (where `Worker` is wired up), add:
```csharp
if (Worker is not null)
{
Worker.PrimeFired += OnPrimeFired;
}
_primeStatusTimer.Elapsed += (_, _) =>
Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null);
```
Add the handler:
```csharp
private void OnPrimeFired(PrimeFiredEvent evt)
{
var when = evt.FiredAt.LocalDateTime.ToString("HH:mm");
PrimeStatus = evt.Success
? $"✓ Primed Claude at {when}"
: $"⚠ Prime failed: {evt.Message}";
_primeStatusTimer.Stop();
_primeStatusTimer.Start();
}
```
- [ ] **Step 2: Surface `PrimeStatus` in the status bar XAML**
Find the existing status bar layout (likely in `MainWindow.axaml` or a dedicated status area within `IslandsShellView` content). Add a `TextBlock` near the connection text:
```xml
<TextBlock Text="{Binding PrimeStatus}"
Foreground="{DynamicResource TextDimBrush}"
FontSize="11"
VerticalAlignment="Center"
Margin="12,0,0,0"
IsVisible="{Binding PrimeStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
```
- [ ] **Step 3: Build + final commit**
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: build succeeds.
```bash
git add src/ClaudeDo.Ui/ src/ClaudeDo.Ui.csproj 2>/dev/null; \
git add src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml \
src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml \
src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml.cs \
src/ClaudeDo.Ui/Views/MainWindow.axaml \
src/ClaudeDo.Ui/Views/MainWindow.axaml.cs \
src/ClaudeDo.Ui/Converters/DateOnlyToDateTimeConverter.cs \
src/ClaudeDo.Ui/Converters/TimeSpanToHhmmConverter.cs \
src/ClaudeDo.Ui/ViewModels/Modals/AboutModalViewModel.cs \
src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
git commit -m "feat(ui): tabbed Settings + Prime Claude tab + About modal + footer prime status"
```
---
## Task 18: Final smoke test (manual)
- [ ] **Step 1: Full solution build**
Run sequentially:
```bash
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
```
Expected: all succeed.
- [ ] **Step 2: Run all tests**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj
```
Expected: all green. New test files contribute additional cases.
- [ ] **Step 3: Manual verification (UI)**
Launch the app. Verify:
1. Open Settings → see 4 tabs: General, Worktrees, Files, Prime Claude.
2. Each existing field still loads its current value and saves correctly (round-trip Save → reopen).
3. Prime Claude tab loads empty initially.
4. "+ Add schedule" appends a row with today / today+30 / 07:00 / MonFri / Enabled.
5. Edit start/end dates, time, weekday flag. Save. Reopen → values persist.
6. Set a schedule with `TimeOfDay = (now + 1 minute)` and `WorkdaysOnly = false`, `StartDate = today`, `EndDate = today`. Save and close.
7. Within ~1 minute the status bar shows `✓ Primed Claude at HH:mm` for ~5 seconds.
8. Reopen Settings → Prime Claude row's "last run" column shows today's timestamp.
9. Help menu → About… opens the new modal; Open buttons work; Close (or Esc) dismisses.
10. The old About section is gone from Settings.
- [ ] **Step 4: Final commit (if any cleanup)**
If manual testing surfaced no fixes, no further commit is needed. Otherwise:
```bash
git add -p
git commit -m "fix: <describe the manual-test finding>"
```
---
## Done
Goal: tabbed Settings, Prime Claude scheduling, About modal, footer notification.