fix(ui): validate PowerUse window, explain effort and Prime Claude in Settings

Three usability-audit findings in Settings:
- An unparseable PowerUse window (e.g. "9:0") used to save silently as null
  (= round the clock), the opposite of what the typo implied. Validate() now
  rejects an unparseable start/end unless blank; the worker-side read-time
  tolerance is unchanged.
- "Effort" column header gets a tooltip explaining it's the model's reasoning
  effort (cost/depth tradeoff).
- The "Prime Claude" tab gets a tooltip explaining scheduled Claude runs
  (Ping / Fill My Day / custom prompt).

New locale keys added to en+de. Updated the PowerUse round-trip test that
asserted the old silent-normalize behavior.
This commit is contained in:
mika kuns
2026-09-10 13:34:06 +02:00
parent 6f1fbe5b93
commit 951c2fe92a
6 changed files with 50 additions and 3 deletions
@@ -11,6 +11,7 @@
"categoryGeneral": "Allgemein", "categoryGeneral": "Allgemein",
"categoryExecution": "Ausführung", "categoryExecution": "Ausführung",
"categoryPrime": "Prime Claude", "categoryPrime": "Prime Claude",
"categoryPrimeHint": "Geplante Claude-Läufe: Ping, My Day füllen oder ein eigener Prompt, zu festen Zeiten.",
"categoryWorktrees": "Worktrees", "categoryWorktrees": "Worktrees",
"categoryFiles": "Dateien & Prompts", "categoryFiles": "Dateien & Prompts",
"categorySkills": "Session Skills", "categorySkills": "Session Skills",
@@ -27,6 +28,7 @@
"model": "Modell", "model": "Modell",
"maxTurns": "Max. Durchläufe", "maxTurns": "Max. Durchläufe",
"effort": "Effort", "effort": "Effort",
"effortHint": "Denkaufwand, den das Modell anwendet. Höher kostet mehr und denkt tiefer.",
"modelPresets": "Vorgaben pro Modell", "modelPresets": "Vorgaben pro Modell",
"modelPresetsHint": "Effort und Durchlauf-Budget, mit denen eine Sitzung unter diesem Modell läuft. Overrides auf Listen- und Aufgabenebene gewinnen weiterhin.", "modelPresetsHint": "Effort und Durchlauf-Budget, mit denen eine Sitzung unter diesem Modell läuft. Overrides auf Listen- und Aufgabenebene gewinnen weiterhin.",
"maxTurnsCeiling": "Max. Durchläufe – Obergrenze", "maxTurnsCeiling": "Max. Durchläufe – Obergrenze",
@@ -50,6 +52,7 @@
"powerUseWindowPlaceholder": "HH:mm", "powerUseWindowPlaceholder": "HH:mm",
"powerUseHandoffPct": "Übergabe bei (% von 5h)", "powerUseHandoffPct": "Übergabe bei (% von 5h)",
"powerUseWindowHint": "Beide Zeiten leer = rund um die Uhr. Das Fenster wird bei jedem Start einer Handler-Phase geprüft — eine Kette, die über das Ende hinausläuft, macht ohne PowerUse weiter.", "powerUseWindowHint": "Beide Zeiten leer = rund um die Uhr. Das Fenster wird bei jedem Start einer Handler-Phase geprüft — eine Kette, die über das Ende hinausläuft, macht ohne PowerUse weiter.",
"powerUseWindowInvalid": "PowerUse-Zeiten müssen im Format HH:mm sein oder leer bleiben.",
"throttleStagesHeading": "Throttle-Stufen", "throttleStagesHeading": "Throttle-Stufen",
"throttleStagesHint": "Die Parallelität wird vor dem harten Gate begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.", "throttleStagesHint": "Die Parallelität wird vor dem harten Gate begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.",
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%", "throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
@@ -11,6 +11,7 @@
"categoryGeneral": "General", "categoryGeneral": "General",
"categoryExecution": "Execution", "categoryExecution": "Execution",
"categoryPrime": "Prime Claude", "categoryPrime": "Prime Claude",
"categoryPrimeHint": "Scheduled Claude runs: Ping, Fill My Day, or a custom prompt, at fixed times.",
"categoryWorktrees": "Worktrees", "categoryWorktrees": "Worktrees",
"categoryFiles": "Files & Prompts", "categoryFiles": "Files & Prompts",
"categorySkills": "Session Skills", "categorySkills": "Session Skills",
@@ -27,6 +28,7 @@
"model": "Model", "model": "Model",
"maxTurns": "Max turns", "maxTurns": "Max turns",
"effort": "Effort", "effort": "Effort",
"effortHint": "Reasoning effort the model applies. Higher costs more and reasons deeper.",
"modelPresets": "Per-model defaults", "modelPresets": "Per-model defaults",
"modelPresetsHint": "Reasoning effort and turn budget used when a session runs under that model. List- and task-level max-turn overrides still win.", "modelPresetsHint": "Reasoning effort and turn budget used when a session runs under that model. List- and task-level max-turn overrides still win.",
"maxTurnsCeiling": "Max turns ceiling", "maxTurnsCeiling": "Max turns ceiling",
@@ -50,6 +52,7 @@
"powerUseWindowPlaceholder": "HH:mm", "powerUseWindowPlaceholder": "HH:mm",
"powerUseHandoffPct": "Hand off at (% of 5h)", "powerUseHandoffPct": "Hand off at (% of 5h)",
"powerUseWindowHint": "Leave both times empty for round the clock. A window is checked whenever a handler phase starts, so a chain that runs past the end time continues without PowerUse.", "powerUseWindowHint": "Leave both times empty for round the clock. A window is checked whenever a handler phase starts, so a chain that runs past the end time continues without PowerUse.",
"powerUseWindowInvalid": "PowerUse window times must be HH:mm, or left blank.",
"throttleStagesHeading": "Throttle stages", "throttleStagesHeading": "Throttle stages",
"throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.", "throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.",
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%", "throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
@@ -119,8 +119,13 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
foreach (var row in ModelPresets) foreach (var row in ModelPresets)
if (row.MaxTurns is < 1 or > 200) if (row.MaxTurns is < 1 or > 200)
return $"Max turns for {row.Model} must be between 1 and 200."; return $"Max turns for {row.Model} must be between 1 and 200.";
if (!IsValidWindowBound(PowerUseWindowStart) || !IsValidWindowBound(PowerUseWindowEnd))
return Loc.T("settings.general.powerUseWindowInvalid");
return null; return null;
} }
private static bool IsValidWindowBound(string? value)
=> string.IsNullOrWhiteSpace(value) || PowerUseWindow.Parse(value) is not null;
} }
/// <summary>One row of the per-model preset table. Single consumer, so it lives here.</summary> /// <summary>One row of the per-model preset table. Single consumer, so it lives here.</summary>
@@ -190,7 +190,8 @@
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="90,12,*,12,110" Margin="0,4,0,0"> <Grid ColumnDefinitions="90,12,*,12,110" Margin="0,4,0,0">
<TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr settings.general.model}"/> <TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr settings.general.model}"/>
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr settings.general.effort}"/> <TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr settings.general.effort}"
ToolTip.Tip="{loc:Tr settings.general.effortHint}"/>
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr settings.general.maxTurns}"/> <TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr settings.general.maxTurns}"/>
</Grid> </Grid>
<ItemsControl ItemsSource="{Binding General.ModelPresets}"> <ItemsControl ItemsSource="{Binding General.ModelPresets}">
@@ -307,7 +308,8 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem Header="{loc:Tr settings.sidebar.categoryPrime}"> <TabItem Header="{loc:Tr settings.sidebar.categoryPrime}"
ToolTip.Tip="{loc:Tr settings.sidebar.categoryPrimeHint}">
<ScrollViewer> <ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0"> <StackPanel Spacing="12" Margin="0,8,0,0">
<Border IsVisible="{Binding !HasLinkedRepo}" <Border IsVisible="{Binding !HasLinkedRepo}"
@@ -30,4 +30,31 @@ public class GeneralSettingsTabViewModelTests
Assert.Equal(expectedValid, vm.Validate() is null); Assert.Equal(expectedValid, vm.Validate() is null);
} }
[Theory]
[InlineData(null, true)]
[InlineData("", true)]
[InlineData("09:00", true)]
[InlineData("23:59", true)]
[InlineData("9:0", false)]
[InlineData("abc", false)]
[InlineData("25:00", false)]
public void Validate_PowerUseWindowStart_rejectsUnparseable(string? value, bool expectedValid)
{
var vm = new GeneralSettingsTabViewModel { PowerUseWindowStart = value };
Assert.Equal(expectedValid, vm.Validate() is null);
}
[Theory]
[InlineData(null, true)]
[InlineData("09:00", true)]
[InlineData("9:0", false)]
[InlineData("abc", false)]
public void Validate_PowerUseWindowEnd_rejectsUnparseable(string? value, bool expectedValid)
{
var vm = new GeneralSettingsTabViewModel { PowerUseWindowEnd = value };
Assert.Equal(expectedValid, vm.Validate() is null);
}
} }
@@ -153,10 +153,17 @@ public class SettingsModalViewModelTests : IDisposable
Assert.Equal("18:00", vm.General.PowerUseWindowStart); Assert.Equal("18:00", vm.General.PowerUseWindowStart);
Assert.Equal(88, vm.General.PowerUseHandoffPct); Assert.Equal(88, vm.General.PowerUseHandoffPct);
// A typo in the window is normalized to null on save, not stored as a fake window. // A typo in the window blocks Save instead of silently becoming "round the clock".
vm.General.PowerUseWindowEnd = "not a time"; vm.General.PowerUseWindowEnd = "not a time";
await vm.SaveCommand.ExecuteAsync(null); await vm.SaveCommand.ExecuteAsync(null);
Assert.Null(worker.Saved);
Assert.NotEqual("", vm.ValidationError);
// Clearing it back out is still a valid "round the clock" window and saves.
vm.General.PowerUseWindowEnd = "";
await vm.SaveCommand.ExecuteAsync(null);
Assert.True(worker.Saved!.PowerUseEnabled); Assert.True(worker.Saved!.PowerUseEnabled);
Assert.Equal("18:00", worker.Saved.PowerUseWindowStart); Assert.Equal("18:00", worker.Saved.PowerUseWindowStart);
Assert.Null(worker.Saved.PowerUseWindowEnd); Assert.Null(worker.Saved.PowerUseWindowEnd);