88 KiB
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(addDbSet) -
Step 1: Create the entity
// 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
// 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):
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
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:
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
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
// 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
// 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
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
// 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
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
// src/ClaudeDo.Worker/Prime/IPrimeClock.cs
namespace ClaudeDo.Worker.Prime;
public interface IPrimeClock { DateTimeOffset Now { get; } }
// 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)
// src/ClaudeDo.Worker/Prime/IPrimeScheduleSignal.cs
namespace ClaudeDo.Worker.Prime;
public interface IPrimeScheduleSignal
{
void Signal();
CancellationToken CurrentToken { get; }
}
// 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
// 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
// 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
// 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
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
// 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
// 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
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
// 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
// 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
// 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
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:
// add to using's:
using ClaudeDo.Worker.Prime;
Change the class declaration:
public sealed class HubBroadcaster : IPrimeBroadcaster
Append:
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
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
usingand injectIPrimeScheduleSignal
Add to imports:
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:
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
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
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:
// 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
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
// 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:
public event Action<PrimeFiredEvent>? PrimeFired;
In the constructor, after the existing _hub.On<...> registrations, add:
_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):
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
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
// 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
// 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
// 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
SettingsModalViewModelto a coordinator
Replace the body of src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs:
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
// 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
// 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
IPrimeScheduleApiand adapter onWorkerClient
Add abstraction (avoids coupling tests to SignalR):
// 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
// 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
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 TabItems. Bindings change from {Binding DefaultModel} to {Binding General.DefaultModel}, etc.
- Step 1: Replace the body
ScrollViewer(lines 83–283 in current file) with aTabControl
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.
<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="Mon–Fri" 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:
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>):
<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:
<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
// 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
// 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)
// 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
<!-- 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
// 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:
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
In src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs (the VM bound as MainWindow's DataContext root), add:
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:
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 likelyIslandsShellViewModel) - Modify: the matching status bar XAML (search for
IsWorkerLogVisibleto 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:
[ObservableProperty] private string? _primeStatus;
private readonly System.Timers.Timer _primeStatusTimer = new(5_000) { AutoReset = false };
In the constructor (where Worker is wired up), add:
if (Worker is not null)
{
Worker.PrimeFired += OnPrimeFired;
}
_primeStatusTimer.Elapsed += (_, _) =>
Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null);
Add the handler:
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
PrimeStatusin 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:
<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
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
Expected: build succeeds.
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:
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
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:
- Open Settings → see 4 tabs: General, Worktrees, Files, Prime Claude.
- Each existing field still loads its current value and saves correctly (round-trip Save → reopen).
- Prime Claude tab loads empty initially.
- "+ Add schedule" appends a row with today / today+30 / 07:00 / Mon–Fri / Enabled.
- Edit start/end dates, time, weekday flag. Save. Reopen → values persist.
- Set a schedule with
TimeOfDay = (now + 1 minute)andWorkdaysOnly = false,StartDate = today,EndDate = today. Save and close. - Within ~1 minute the status bar shows
✓ Primed Claude at HH:mmfor ~5 seconds. - Reopen Settings → Prime Claude row's "last run" column shows today's timestamp.
- Help menu → About… opens the new modal; Open buttons work; Close (or Esc) dismisses.
- 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:
git add -p
git commit -m "fix: <describe the manual-test finding>"
Done
Goal: tabbed Settings, Prime Claude scheduling, About modal, footer notification.