# 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 `` 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 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 <= 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/`: ```csharp 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` 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` / `PrimeClock` — `DateTimeOffset Now { get; }`. Faked in tests. - `PrimeSchedulerOptions` — `CatchUpWindow = TimeSpan.FromMinutes(30)`. Hardcoded today; typed for swappability. ### Loop ```text 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`) ```csharp Task> ListPrimeSchedules(); Task UpsertPrimeSchedule(PrimeScheduleDto dto); Task DeletePrimeSchedule(Guid id); ``` DTO mirrors entity minus `CreatedAt` (server-managed). ### Hub events (broadcast) ```csharp 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: ```csharp 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 Rows` - `[RelayCommand] AddSchedule()` / `RemoveSchedule(id)` - Subscribes to `WorkerClient.PrimeSchedulesChanged` / `PrimeFired` to keep rows fresh while modal is open. ### Footer notification `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: "`. ### About wiring - `MainWindowViewModel` adds `[RelayCommand] OpenAbout()` — opens `AboutModalView` via the existing dialog factory pattern. - `MainWindow.axaml` Help menu gains ``. ## 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).