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

88 KiB
Raw Permalink Blame History

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

// 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 using and inject IPrimeScheduleSignal

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 SettingsModalViewModel to 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 IPrimeScheduleApi and adapter on WorkerClient

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

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

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.

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:

[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 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:

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

  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:

git add -p
git commit -m "fix: <describe the manual-test finding>"

Done

Goal: tabbed Settings, Prime Claude scheduling, About modal, footer notification.