13 KiB
Tabbed Settings + Prime Claude — Design
Date: 2026-04-28 Status: Draft for review
Goal
Two related UI changes:
- Restructure the existing Settings modal from a single scrollable stack into a
TabControlwith focused tabs. Move the read-only "About" content out of Settings entirely, into a new modal accessible from the existing Help menu. - 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 1call 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
MainWindowHelp menu. - New
PrimeSchedulestable, repository, EF migration. - New
PrimeSchedulerbackground 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
MainWindowHelp menu as a new<MenuItem Header="About…">next to "Check for updates". - About content removed from
SettingsModalViewentirely (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 Mon–Fri 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 <= EndDateTimeOfDayparses asHH:mmEndDate >= 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>inConfiguration/. - New repository
PrimeScheduleRepositorymatching 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 actualclaude -p "ping" --max-turns 1call. Injected so tests can fake it.IPrimeClock/PrimeClock—DateTimeOffset Now { get; }. Faked in tests.PrimeSchedulerOptions—CatchUpWindow = 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 >= todaywithin[StartDate, EndDate], honoringWorkdaysOnly. - 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 + 30minand not already fired today, target =now(fire immediately). - If
target < now(past catch-up window) andd == today, advancedto next eligible date.
- Determine the 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):
- Resolve
claudeexecutable via existingClaudeProcessdiscovery. - Spawn with
cwd = Paths.AppDataRoot(), args["-p", "ping", "--max-turns", "1"]. No worktree, no task entity, no list/tag side effects. - Capture stdout/stderr; success = exit 0 within a 60s timeout.
- On finish:
await PrimeScheduleRepository.UpdateLastRunAsync(id, now), append a one-line summary to~/.todo-app/logs/prime.log, broadcastPrimeFired(success, message, timestamp)viaHubBroadcaster.
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()andTask 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/PrimeFiredto keep rows fresh while modal is open.
Footer notification
StatusBarViewModel:
- New
string? PrimeStatusproperty. - Subscribes to
WorkerClient.PrimeFired. - On event: set
PrimeStatus, start aDispatcherTimerfor 5s, clear on tick. StatusBarViewgets aTextBlockbound toPrimeStatus, right-aligned, dim-foreground, only visible when non-empty.
Format: "✓ Primed Claude at 07:01" or "⚠ Prime failed: <reason>".
About wiring
MainWindowViewModeladds[RelayCommand] OpenAbout()— opensAboutModalViewvia the existing dialog factory pattern.MainWindow.axamlHelp 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
LastRunAtis 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.
PrimeFiredevent updates the matching row'sLastRunAt.
ClaudeDo.Ui.Tests/ViewModels/StatusBarViewModelTests.cs (extend existing if present, else new)
PrimeFiredsetsPrimeStatusand clears it after 5s (use a fakeIDispatcherTimeror 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).