Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs
T
mika kuns 69898be5c9 feat(usage): PowerUse-Zeitfenster + Handoff-Schwellwert als Settings
Drei neue app_settings-Spalten (Migration AddPowerUseWindow):
power_use_window_start/end (HH:mm, lokal, nullable) und
power_use_handoff_pct (NOT NULL DEFAULT 95, geclampt 50-99).

Die Fensterlogik liegt als reine Funktion in PowerUseWindow
(Contains/Normalize/IsOpen): beide Grenzen leer oder nur eine gesetzt =
rund um die Uhr, Ende vor Start wrappt ueber Mitternacht, Grenzen
halboffen. Geprueft wird in ComposeHandlerPromptAsync, also pro Phase
zum Zeitpunkt des Prompt-Baus - die Uhr ist per Func<DateTime>
injizierbar. Bedienung im Usage-Monitor neben dem PowerUse-Toggle
(Commit auf Blur/Enter, ein Write fuer alle vier Felder); in
Settings->General unsichtbar, aber load->save durchgereicht.

Nebenbefund, gleicher Pfad: AppSettingsRepository.UpdateAsync kopiert
Feld fuer Feld und hat ModelPresets, PowerUseEnabled und
ListHandlerMaxHandoffs nie geschrieben - jeder Save dieser drei war
still wirkungslos. Mit ergaenzt und per Round-trip-Test gepinnt.
2026-09-02 19:09:47 +02:00

248 lines
12 KiB
C#

using System.Linq;
using ClaudeDo.Data;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals;
/// <summary>One sidebar entry. <c>Group</c> is an internal split key ("Basis"/"Erweitert"), not
/// user-facing text — the sidebar renders the two groups as separate lists with their own
/// (localized) heading. <c>RequiresRepo</c> marks the categories that show the repo-hint strip.</summary>
public sealed record SettingsCategory(string Key, LocalizedString Label, string Group, bool IsVisible, bool RequiresRepo);
public sealed partial class SettingsModalViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public GeneralSettingsTabViewModel General { get; }
public WorktreesSettingsTabViewModel Worktrees { get; }
public FilesSettingsTabViewModel Files { get; }
public PrimeClaudeTabViewModel Prime { get; }
public OnlineInboxSettingsViewModel OnlineInbox { get; }
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
public TicketSettingsTabViewModel Tickets { get; }
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false;
// Fixed order — mirrors the TabItem order in SettingsModalView.axaml 1:1, so
// _categories.IndexOf(category) is a valid TabControl.SelectedIndex.
private readonly List<SettingsCategory> _categories;
public IReadOnlyList<SettingsCategory> Categories => _categories;
public IReadOnlyList<SettingsCategory> BasisCategories { get; }
public IReadOnlyList<SettingsCategory> ErweitertCategories { get; }
private SettingsCategory? _selectedCategory;
public SettingsCategory? SelectedCategory
{
get => _selectedCategory;
set
{
// The two sidebar ListBoxes share this property; selecting an item in one pushes null
// into the other's binding (item not found in its own ItemsSource) — ignore that noise
// instead of letting it clobber the real selection.
if (value is null) return;
if (SetProperty(ref _selectedCategory, value))
SelectedIndex = _categories.IndexOf(value);
}
}
[ObservableProperty] private int _selectedIndex;
// True once at least one list has a linked repo (WorkingDir). Drives the repo-hint strip on
// Worktrees/Prime Claude/Session Skills/Berichte — nothing is ever hidden, just annotated.
// Scope intentionally broader than ListsIslandViewModel.HasNoLinkedRepo (all Lists rows in
// the DB, not just UserLists).
[ObservableProperty] private bool _hasLinkedRepo;
// Wired by WindowDialogService to close this modal and open the existing repo-import flow.
public Action? RequestRepoImport { get; set; }
[RelayCommand]
private void OpenRepoImport() => RequestRepoImport?.Invoke();
// Same deal for the usage monitor, which owns the throttle-stage editor. It must *close* this
// modal rather than stack on top: Save() writes the throttle values it read at open, so a drag
// made while Settings is still open would be overwritten on save.
public Action? RequestUsageMonitor { get; set; }
[RelayCommand]
private void OpenUsageMonitor() => RequestUsageMonitor?.Invoke();
/// Read-only echo of the four throttle percentages, which are edited by dragging the
/// usage-monitor gauges — without this the Execution tab silently carries them through.
[ObservableProperty] private string _throttleSummary = "";
[ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _statusMessage = "";
public Action? CloseAction { get; set; }
public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime,
OnlineLoginService onlineLoginService,
ILocalizer localizer, AppSettings appSettings,
IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_worker = worker;
_dbFactory = dbFactory;
General = new GeneralSettingsTabViewModel(localizer, code =>
{
appSettings.Language = code;
appSettings.Save();
});
General.InitAccentPresets(appSettings.AccentPreset, preset =>
{
appSettings.AccentPreset = preset;
appSettings.Save();
});
Worktrees = new WorktreesSettingsTabViewModel(worker);
Files = new FilesSettingsTabViewModel(worker);
Prime = prime;
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
Tickets = new TicketSettingsTabViewModel(worker);
_categories = new List<SettingsCategory>
{
new("General", new LocalizedString(localizer, "settings.sidebar.categoryGeneral"), "Basis", true, false),
new("Execution", new LocalizedString(localizer, "settings.sidebar.categoryExecution"), "Basis", true, false),
new("Prime", new LocalizedString(localizer, "settings.sidebar.categoryPrime"), "Basis", true, true),
new("Worktrees", new LocalizedString(localizer, "settings.sidebar.categoryWorktrees"), "Erweitert", true, true),
new("Files", new LocalizedString(localizer, "settings.sidebar.categoryFiles"), "Erweitert", true, false),
new("SessionSkills", new LocalizedString(localizer, "settings.sidebar.categorySkills"), "Erweitert", true, true),
new("Reports", new LocalizedString(localizer, "settings.sidebar.categoryReports"), "Erweitert", true, true),
new("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, false),
new("Tickets", new LocalizedString(localizer, "settings.sidebar.categoryTickets"), "Erweitert", TicketSettingsTabViewModel.FeatureEnabled, false),
};
BasisCategories = _categories.Where(c => c.Group == "Basis").ToList();
ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList();
_selectedCategory = _categories[0];
}
// Throttle stages are edited by dragging the usage-monitor gauges, not on any Settings tab —
// carried through load→save verbatim so saving Settings can never reset a dragged value.
private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65);
// Same deal: the PowerUse master switch is toggled on the usage monitor. Carried load→save so
// opening and saving Settings can't switch it off behind the user's back.
private bool _powerUseEnabled;
// Ditto for the PowerUse time window and handoff threshold — usage-monitor-owned, invisible
// here, but they must survive a Settings save.
private (string? Start, string? End, int HandoffPct) _powerUseWindow = (null, null, 95);
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.MaxTurnsCeiling = dto.MaxTurnsCeiling;
General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto";
General.MaxParallelExecutions = dto.MaxParallelExecutions;
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
General.AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit;
General.ListHandlerMaxHandoffs = dto.ListHandlerMaxHandoffs;
// Owned by the usage monitor, not editable here — but it MUST be round-tripped,
// because SaveAsync rebuilds the whole DTO positionally and would otherwise write
// the record default (false) back and silently switch PowerUse off.
_powerUseEnabled = dto.PowerUseEnabled;
_powerUseWindow = (dto.PowerUseWindowStart, dto.PowerUseWindowEnd, dto.PowerUseHandoffPct);
_throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
ThrottleSummary = Loc.T("settings.general.throttleStagesValues",
_throttleStages.FiveSoft, _throttleStages.FiveHard,
_throttleStages.SevenSoft, _throttleStages.SevenHard);
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
Worktrees.WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays;
General.ReportExcludedPaths = string.IsNullOrWhiteSpace(dto.ReportExcludedPaths)
? @"C:\Private"
: string.Join(Environment.NewLine,
System.Text.Json.JsonSerializer.Deserialize<List<string>>(dto.ReportExcludedPaths) ?? new());
General.StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday;
Prime.DailyPrepMaxTasks = dto.DailyPrepMaxTasks < 1 ? 5 : dto.DailyPrepMaxTasks;
}
else StatusMessage = Loc.T("vm.settingsModal.workerOffline");
await Files.LoadClaudeBinAsync();
await Prime.LoadAsync();
await OnlineInbox.LoadAsync();
await SessionSkills.LoadAsync(dto?.SessionSkills);
await Tickets.LoadAsync();
General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling);
await using var ctx = await _dbFactory.CreateDbContextAsync();
HasLinkedRepo = await ctx.Lists.AnyAsync(RepoLinkage.IsLinkedInDb);
}
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",
General.MaxParallelExecutions,
Worktrees.WorktreeStrategy ?? "sibling",
string.IsNullOrWhiteSpace(Worktrees.CentralWorktreeRoot) ? null : Worktrees.CentralWorktreeRoot,
Worktrees.WorktreeAutoCleanupEnabled,
Worktrees.WorktreeAutoCleanupDays,
System.Text.Json.JsonSerializer.Serialize(
General.ReportExcludedPaths
.Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()),
General.StandupWeekday,
Prime.DailyPrepMaxTasks,
SessionSkills.SelectedSkillNames(),
General.ModelPresetDtos(),
General.UsageGateFiveHourPct,
General.UsageGateSevenDayPct,
General.MaxTurnsCeiling,
_throttleStages.FiveSoft,
_throttleStages.FiveHard,
_throttleStages.SevenSoft,
_throttleStages.SevenHard,
General.AutoContinueOnUsageLimit,
_powerUseEnabled,
General.ListHandlerMaxHandoffs,
_powerUseWindow.Start,
_powerUseWindow.End,
_powerUseWindow.HandoffPct);
await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
await OnlineInbox.SaveAsync();
await Tickets.SaveAsync();
CloseAction?.Invoke();
}
catch (Exception ex) { StatusMessage = Loc.T("vm.settingsModal.saveFailed", ex.Message); }
finally { IsBusy = false; }
}
[RelayCommand] private void Cancel() => CloseAction?.Invoke();
}