Files
ClaudeDo/docs/superpowers/specs/2026-04-28-tabbed-settings-prime-claude-design.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

13 KiB
Raw Permalink Blame History

Tabbed Settings + Prime Claude — Design

Date: 2026-04-28 Status: Draft for review

Goal

Two related UI changes:

  1. Restructure the existing Settings modal from a single scrollable stack into a TabControl with focused tabs. Move the read-only "About" content out of Settings entirely, into a new modal accessible from the existing Help menu.
  2. Add a new Prime Claude tab where the user defines date-bounded daily schedules. At each scheduled time, the worker fires a single non-interactive claude -p "ping" --max-turns 1 call to start Claude's 5-hour usage window early — "priming" the day.

Scope

In scope

  • Settings tabbed UI with 4 tabs: General, Worktrees, Files, Prime Claude.
  • New About modal opened from MainWindow Help menu.
  • New PrimeSchedules table, repository, EF migration.
  • New PrimeScheduler background service (event-driven, no polling).
  • New SignalR hub methods + client wiring.
  • Footer notification on prime fire (success/failure) via StatusBarView.
  • 30-minute catch-up window on app launch / wake.
  • Tests: scheduler unit tests, tab VM tests.

Out of scope

  • Auto-start ClaudeDo at OS boot.
  • Multiple pings per day per schedule.
  • Per-schedule prompt customization (schema reserves the column for future use).
  • Holiday / calendar integration.
  • Toast notifications, sound, OS-level notifications.

Settings tab layout

Tab Contents (existing sections, no field changes)
General Claude Defaults: instructions, model, max turns, permission mode
Worktrees Strategy, central root, auto-cleanup, Cleanup button, Force-remove confirm flow
Files Agents (Restore default agents) + Prompts (System / Planning / Agent open-in-editor rows)
Prime Claude New — schedule list + add button (see below)
  • Window stays 580×760, custom title bar preserved.
  • Footer (Save / Cancel) preserved; Save iterates per-tab VMs.
  • Status / validation strip stays above the footer.
  • Tab strip uses the existing section-label style for headers (mono, 10pt, letter-spacing 1.4) so it visually matches the current aesthetic.

About modal

New AboutModalView + AboutModalViewModel:

  • Same 4 rows as today's About section: Version, Data folder, Logs folder, Worker config — each with an Open button.
  • Compact dialog (~480×280), same chrome as SettingsModalView.
  • Wired into MainWindow Help menu as a new <MenuItem Header="About…"> next to "Check for updates".
  • About content removed from SettingsModalView entirely (cleaner: not a setting).

Prime Claude tab — UI

┌────────────────────────────────────────────────────────────────┐
│ 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 min of the target │
│ time, the ping fires immediately (catch-up window).             │
├────────────────────────────────────────────────────────────────┤
│ ☑  May 5, 2026 → Jun 30, 2026   07:00   MonFri   last: today  ✕│
│ ☐  Jul 1, 2026 → Jul 7, 2026    09:30   All days  —             ✕│
├────────────────────────────────────────────────────────────────┤
│ [+ Add schedule]                                                │
└────────────────────────────────────────────────────────────────┘

Per-row controls:

  • Enabled checkbox (Enabled)
  • Start date picker (StartDate)
  • End date picker (EndDate)
  • Time-of-day field (TimeOfDay, 24h, e.g. 07:00)
  • Workdays-only checkbox (WorkdaysOnly)
  • Last run label ({LastRunAt:g} or if null)
  • Delete button (✕, with inline confirm bar matching the Worktrees pattern)

+ Add schedule appends a new row pre-filled with: today, today + 30 days, 07:00, WorkdaysOnly = true, Enabled = true.

Validation per row:

  • StartDate <= EndDate
  • TimeOfDay parses as HH:mm
  • EndDate >= today (else mark row disabled-looking + tooltip "expired")

Persistence: rows save with the rest of the modal on Save. On Save, PrimeClaudeTabViewModel diffs in-memory rows against the loaded snapshot and emits one hub call per change: UpsertPrimeSchedule for new/edited rows, DeletePrimeSchedule for removed rows. Cancel discards in-memory edits. No per-row autosave.

Data model

New EF Core entity PrimeScheduleEntity in ClaudeDo.Data/Models/:

public class PrimeScheduleEntity
{
    public Guid Id { get; set; }
    public DateOnly StartDate { get; set; }
    public DateOnly EndDate { get; set; }
    public TimeSpan TimeOfDay { get; set; }     // local clock
    public bool WorkdaysOnly { get; set; }
    public bool Enabled { get; set; }
    public DateTimeOffset? LastRunAt { get; set; }
    public string? PromptOverride { get; set; }  // reserved, always null today
    public DateTimeOffset CreatedAt { get; set; }
}
  • New PrimeScheduleConfiguration : IEntityTypeConfiguration<PrimeScheduleEntity> in Configuration/.
  • New repository PrimeScheduleRepository matching the existing async + CancellationToken pattern. Methods: ListAsync, GetAsync(id), UpsertAsync(entity), DeleteAsync(id), UpdateLastRunAsync(id, when).
  • EF migration AddPrimeSchedules (auto-named per existing migration history).

Worker scheduler — PrimeScheduler

New folder ClaudeDo.Worker/Prime/. Class hierarchy:

  • PrimeScheduler : BackgroundService — event-driven loop.
  • IPrimeRunner / PrimeRunner — fires the actual claude -p "ping" --max-turns 1 call. Injected so tests can fake it.
  • IPrimeClock / PrimeClockDateTimeOffset Now { get; }. Faked in tests.
  • PrimeSchedulerOptionsCatchUpWindow = TimeSpan.FromMinutes(30). Hardcoded today; typed for swappability.

Loop

while not cancelled:
    next = ComputeNextDue(now)        # null if no enabled schedules
    if next is null:
        await wait-on-signal           # blocks until schedules change
        continue
    delay = max(0, next.At - now)
    try:
        await Task.Delay(delay, linkedToken)   # cancellable by signal
    catch OperationCanceledException:
        continue                       # schedules changed → recompute
    await Fire(next.Schedule)

ComputeNextDue(now):

  • For each enabled schedule:
    • Determine the next eligible date d >= today within [StartDate, EndDate], honoring WorkdaysOnly.
    • Skip the day if LastRunAt.LocalDate == today (already fired today).
    • Build target = d.At(TimeOfDay) in local time.
    • Apply catch-up: if target < now <= target + 30min and not already fired today, target = now (fire immediately).
    • If target < now (past catch-up window) and d == today, advance d to next eligible date.
  • Return the schedule with the smallest target.

Signal source

IPrimeScheduleSignal — a thin abstraction wrapping a CancellationTokenSource reset. The hub calls Signal() on:

  • App start (initial recompute is implicit — service first-run computes immediately).
  • After UpsertPrimeSchedule / DeletePrimeSchedule.
  • After a successful fire (so the next-due is recomputed without polling).

Fire

PrimeRunner.FireAsync(schedule, ct):

  1. Resolve claude executable via existing ClaudeProcess discovery.
  2. Spawn with cwd = Paths.AppDataRoot(), args ["-p", "ping", "--max-turns", "1"]. No worktree, no task entity, no list/tag side effects.
  3. Capture stdout/stderr; success = exit 0 within a 60s timeout.
  4. On finish: await PrimeScheduleRepository.UpdateLastRunAsync(id, now), append a one-line summary to ~/.todo-app/logs/prime.log, broadcast PrimeFired(success, message, timestamp) via HubBroadcaster.

Failure modes (network, auth, executable missing) → broadcast a failure message; LastRunAt still stamped so the day doesn't keep retrying.

SignalR / IPC

Hub methods (WorkerHub)

Task<IReadOnlyList<PrimeScheduleDto>> ListPrimeSchedules();
Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto);
Task DeletePrimeSchedule(Guid id);

DTO mirrors entity minus CreatedAt (server-managed).

Hub events (broadcast)

event PrimeFired(Guid scheduleId, bool success, string message, DateTimeOffset firedAt);

The scheduleId lets an open Settings modal update the matching row's LastRunAt without a full reload. No separate PrimeSchedulesChanged event — Settings is the only writer, so the modal's own VM state is authoritative until Save.

WorkerClient adds matching async methods + the event handler.

UI wiring

ViewModel split

SettingsModalViewModel stops holding field properties directly and becomes a coordinator:

public sealed partial class SettingsModalViewModel
{
    public GeneralSettingsTabViewModel General { get; }
    public WorktreesSettingsTabViewModel Worktrees { get; }
    public FilesSettingsTabViewModel Files { get; }
    public PrimeClaudeTabViewModel Prime { get; }

    [RelayCommand] private async Task Save() { ... iterate tabs, call SaveAsync on each ... }
}

Each tab VM:

  • Owns its observable properties.
  • Has Task LoadAsync() and Task SaveAsync() (or returns a partial DTO the coordinator merges).
  • Owns its own validation, surfaces ValidationError.

PrimeClaudeTabViewModel:

  • ObservableCollection<PrimeScheduleRowViewModel> Rows
  • [RelayCommand] AddSchedule() / RemoveSchedule(id)
  • Subscribes to WorkerClient.PrimeSchedulesChanged / PrimeFired to keep rows fresh while modal is open.

StatusBarViewModel:

  • New string? PrimeStatus property.
  • Subscribes to WorkerClient.PrimeFired.
  • On event: set PrimeStatus, start a DispatcherTimer for 5s, clear on tick.
  • StatusBarView gets a TextBlock bound to PrimeStatus, right-aligned, dim-foreground, only visible when non-empty.

Format: "✓ Primed Claude at 07:01" or "⚠ Prime failed: <reason>".

About wiring

  • MainWindowViewModel adds [RelayCommand] OpenAbout() — opens AboutModalView via the existing dialog factory pattern.
  • MainWindow.axaml Help menu gains <MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>.

Tests

ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs

Real SQLite, fake IPrimeClock, fake IPrimeRunner. Cases:

  • Fires once at exact target time.
  • Fires immediately on startup if within catch-up window.
  • Skips firing if past catch-up window (waits for next eligible day).
  • Honors WorkdaysOnly (no fire on Sat/Sun).
  • Honors date range (no fire before StartDate, none after EndDate).
  • Idempotent: doesn't double-fire if LastRunAt is today.
  • Recomputes on signal (upsert mid-wait).
  • Disabling a schedule mid-wait recomputes.

ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs

Cases:

  • Add row appends with sensible defaults.
  • Remove row removes from collection.
  • Validation: StartDate > EndDate flags row as invalid.
  • Save serializes all rows to repository in one batch.
  • PrimeFired event updates the matching row's LastRunAt.

ClaudeDo.Ui.Tests/ViewModels/StatusBarViewModelTests.cs (extend existing if present, else new)

  • PrimeFired sets PrimeStatus and clears it after 5s (use a fake IDispatcherTimer or an injectable delay).

Migration / rollout

  • Single EF migration AddPrimeSchedules. Existing DBs upgrade on next launch via the existing migration runner (no manual step).
  • No data backfill — table starts empty. Users add schedules manually via the new tab.
  • Backwards compatibility for AppSettingsEntity: untouched.

Risks & mitigations

Risk Mitigation
App is closed at scheduled time 30 min catch-up on launch; explicit copy in tab explains the limitation.
Clock/timezone change while waiting Task.Delay fires on monotonic time; recompute after each fire catches drift on next iteration. Acceptable for a 5h-window primer.
Claude CLI hangs 60s timeout on the spawn; failure stamped + broadcast.
Multiple ClaudeDo instances on same machine Out of scope (existing app already assumes single instance via fixed SignalR port).
User edits schedule while scheduler is mid-fire Fire completes, then signal triggers recompute. No race — UpdateLastRunAsync is the last write.

Open questions

None at design time. Implementation may surface small details (e.g. exact Avalonia controls for date/time pickers — likely CalendarDatePicker + a TextBox masked to HH:mm since Avalonia 12 has no built-in TimePicker on all platforms).