feat(ui): PowerUse-Einstellungen nach Settings -> Ausfuehrung verschieben

Schalter, Zeitfenster und Handoff-Prozent lagen im Usage-Monitor und mussten
in SettingsModalViewModel nur load->save durchgeschleift werden, damit ein
Settings-Speichern sie nicht zurueckwirft. Jetzt gehoeren sie dem General-Tab,
der Monitor ist wieder nur Anzeige + Threshold-Drag, und "Jetzt aktualisieren"
sitzt als Icon in der Titelleiste (neuer ModalShell.HeaderActions-Slot).
This commit is contained in:
mika kuns
2026-09-07 08:17:37 +02:00
parent c7392e085f
commit 7c7c33d677
21 changed files with 179 additions and 213 deletions
+1 -1
View File
@@ -121,7 +121,7 @@ per call and `Register` clamps to min 1, so 0 can never mean "unbounded"). Past
`wait`/`merge` request is silently coerced to its `_final` counterpart — check the result's
`coerced` flag — and any handoff after a `merge_final` round throws.
**PowerUse.** When `AppSettingsEntity.PowerUseEnabled` (toggled on the usage monitor) **and** the
**PowerUse.** When `AppSettingsEntity.PowerUseEnabled` (Settings → Execution) **and** the
list's own `ListConfigEntity.PowerUseAllowed` are both set, `InteractiveLaunchSpecService.
ComposeHandlerPromptAsync` appends `PromptKind.PowerUseAddendum` to whichever handler prompt the
phase resolved to. The addendum is written as an *override* of the base prompt's interactive rules
+3 -2
View File
@@ -254,6 +254,7 @@ degrades instead of throwing. Worker config: `usage_poll_interval_active_seconds
`usage_poll_interval_idle_seconds`.
The gate percentages are editable in **two** places that both write the same `app_settings` row:
Settings → General (typed) and the usage-monitor gauges (dragged). The throttle stages are
Settings → Execution (typed) and the usage-monitor gauges (dragged). The throttle stages are
gauge-only — `SettingsModalViewModel` therefore carries them load→save verbatim so saving Settings
can't reset a dragged value.
can't reset a dragged value. PowerUse (switch, time window, handoff %) used to be usage-monitor-only
and is now typed on the same Settings tab, so it needs no such round-trip.
+2 -2
View File
@@ -39,10 +39,10 @@ Beyond the basics it carries:
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
| `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. |
| `PowerUseEnabled` | `power_use_enabled` | false | PowerUse master switch, toggled on the **usage monitor** (not Settings). ⚠️ Never sufficient alone — a handler run is in PowerUse only when `list_config.power_use_allowed` is also set. |
| `PowerUseEnabled` | `power_use_enabled` | false | PowerUse master switch, toggled in **Settings → Execution**. ⚠️ Never sufficient alone — a handler run is in PowerUse only when `list_config.power_use_allowed` is also set. |
| `ListHandlerMaxHandoffs` | `list_handler_max_handoffs` | 4 | Cap on a list-handler run's wait↔merge handoff chain (`HandoffRoundTracker`). 4 = two merge rounds. Independent of PowerUse — applies to interactive runs too. Clamped to min 1 in `WorkerHub.UpdateAppSettings` and again in `Register`, because 0 would remove the only bound on a runaway chain. |
| `PowerUseWindowStart` / `PowerUseWindowEnd` | `power_use_window_*` | null | Optional local-time window (`HH:mm`) narrowing when PowerUse applies. Both null (or only one set) = round the clock, i.e. byte-identical to the pre-window behaviour; end before start wraps over midnight. Semantics live in the pure `PowerUseWindow`; checked in `InteractiveLaunchSpecService.ComposeHandlerPromptAsync` **per phase** at prompt-build time, so a chain handing off past the end time drops the addendum from there on. Unparseable input is stored as null, not rejected. |
| `PowerUseHandoffPct` | `power_use_handoff_pct` | 95 | 5h-usage percentage at which a PowerUse handler session should hand off. Clamped 50..99. Stored + shown on the usage monitor; the consumer is the handoff prompt. |
| `PowerUseHandoffPct` | `power_use_handoff_pct` | 95 | 5h-usage percentage at which a PowerUse handler session should hand off. Clamped 50..99. Typed in Settings → Execution; the consumer is the handoff prompt. |
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
@@ -62,8 +62,7 @@ public sealed class AppSettingsEntity
public bool AutoContinueOnUsageLimit { get; set; }
// Master switch for PowerUse: list-handler runs go unattended (never ask the user) and harvest
// new work when a round would otherwise starve. Toggled from the usage monitor, because it is a
// deliberate "spend the remaining window" decision rather than a normal default. Off by default,
// new work when a round would otherwise starve. Toggled in Settings → Execution. Off by default,
// and on its own it changes nothing — a run is only in PowerUse when the LIST also opted in via
// ListConfigEntity.PowerUseAllowed, so leaving this on cannot make an unrelated list's handler
// run unattended. Composition lives in InteractiveLaunchSpecService.
+3 -4
View File
@@ -37,13 +37,12 @@ public record AppSettingsDto(
int UsageThrottleSevenDaySoftPct = 50,
int UsageThrottleSevenDayHardPct = 65,
bool AutoContinueOnUsageLimit = false,
// PowerUse master switch — toggled on the usage monitor, not typed in Settings. Only takes
// effect for a list that also set PowerUseAllowed.
// PowerUse master switch — Settings → Execution. Only takes effect for a list that also set
// PowerUseAllowed.
bool PowerUseEnabled = false,
int ListHandlerMaxHandoffs = 4,
// PowerUse time window (`HH:mm`, local, null = round the clock) + the 5h-usage percentage at
// which a PowerUse handler session should hand off. Also usage-monitor-owned, so Settings has
// to round-trip them too.
// which a PowerUse handler session should hand off. Same Settings tab.
string? PowerUseWindowStart = null,
string? PowerUseWindowEnd = null,
int PowerUseHandoffPct = 95);
+27 -10
View File
@@ -43,6 +43,13 @@
"autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Queue genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.",
"listHandlerMaxHandoffs": "Handoff-Budget des List-Handlers",
"listHandlerMaxHandoffsHint": "Wie viele Wait/Merge-Handoffs ein „Let Claude handle it\"-Run verketten darf. 4 entspricht zwei Merge-Runden. Für Läufe, die stundenlang durchlaufen sollen, höher setzen; der Wert begrenzt eine entlaufene Kette und darf daher nicht 0 sein.",
"powerUseHeading": "PowerUse",
"powerUse": "PowerUse aktivieren",
"powerUseHint": "Das restliche Fenster ausnutzen: ein List-Handler-Run fragt nichts, löst Konflikte selbst, sammelt neue Arbeit und handoffet weiter, solange sein Budget reicht. Wirkt nur auf Listen, die PowerUse in ihren Listen-Einstellungen erlaubt haben.",
"powerUseWindow": "Nur zwischen",
"powerUseWindowPlaceholder": "HH:mm",
"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.",
"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.",
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
@@ -332,6 +339,7 @@
"failed": "Fehlgeschlagen",
"roadblock": "Blockade gemeldet",
"openSession": "Sitzung öffnen",
"homeFolder": "Home (Schnellsitzung)",
"browseForFolder": "Ordner wählen…",
"focusMode": "Fokus",
"overviewMode": "Übersicht",
@@ -407,7 +415,7 @@
"findingsTracked": "Findings-Ordner .claudedo einchecken",
"findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.",
"powerUseAllowed": "PowerUse für diese Liste erlauben",
"powerUseAllowedHint": "Erlaubt einem List-Handler-Run auf dieser Liste, unbeaufsichtigt zu laufen, sobald PowerUse im Usage-Monitor eingeschaltet ist: er fragt nichts, löst Konflikte selbst und sammelt neue Arbeit. Beide Schalter sind nötig — ein global aktives PowerUse ändert an einer Liste ohne Opt-in also nichts.",
"powerUseAllowedHint": "Erlaubt einem List-Handler-Run auf dieser Liste, unbeaufsichtigt zu laufen, sobald PowerUse in Einstellungen → Ausführung eingeschaltet ist: er fragt nichts, löst Konflikte selbst und sammelt neue Arbeit. Beide Schalter sind nötig — ein global aktives PowerUse ändert an einer Liste ohne Opt-in also nichts.",
"sectionIcon": "ICON",
"icon": "Listen-Icon",
"iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.",
@@ -510,18 +518,18 @@
"regenerate": "Neu erstellen",
"emptyStateHint": "Noch kein Bericht für diesen Zeitraum. Klicke auf „Erstellen“."
},
"cheatSheets": {
"title": "CHEAT SHEETS",
"windowTitle": "Cheat Sheets",
"pin": "Anpinnen",
"pinTooltip": "Fenster über den anderen halten",
"missing": "Cheat Sheet {0} nicht gefunden."
},
"usageMonitor": {
"title": "USAGE MONITOR",
"windowTitle": "Usage Monitor",
"noGauges": "Noch keine Nutzungslimits gemeldet.",
"powerUse": "PowerUse",
"powerUseHint": "Das restliche Fenster ausnutzen: ein List-Handler-Run fragt nichts, löst Konflikte selbst, sammelt neue Arbeit und handoffet weiter, solange sein Budget reicht. Wirkt nur auf Listen, die PowerUse in ihren Listen-Einstellungen erlaubt haben.",
"powerUseWindow": "Nur zwischen",
"powerUseWindowPlaceholder": "HH:mm",
"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.",
"refresh": "Jetzt aktualisieren",
"refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
"refreshHint": "Jetzt aktualisieren — Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
"staleFormat": "Werte veraltet (Stand {0})",
"staleGateHint": "Das Gate greift in diesem Zustand nicht.",
"gateBlockedFormat": "Warteschlange pausiert — {0}",
@@ -710,6 +718,9 @@
"abort": "Merge abbrechen"
},
"controls": {
"markdown": {
"copy": "Befehl kopieren"
},
"datePicker": {
"today": "Heute",
"tomorrow": "Morgen",
@@ -729,6 +740,13 @@
"worktrees": "Worktrees…",
"weeklyReport": "Wochenbericht…",
"usageMonitor": "Usage Monitor…",
"cheatSheets": "Cheat Sheets",
"cheatSheetGit": "Git…",
"cheatSheetCommits": "Conventional Commits…",
"cheatSheetClaude": "Claude CLI…",
"cheatSheetClaudeConfig": "Claude-Konfiguration…",
"cheatSheetDotnet": "dotnet CLI…",
"cheatSheetPowershell": "PowerShell…",
"about": "Über…",
"addRepos": "Repos als Listen hinzufügen…"
},
@@ -765,7 +783,6 @@
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}",
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}",
"powerUseSaveFailed": "PowerUse-Schalter konnte nicht gespeichert werden: {0}",
"installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.",
"installFailed": "TokenTracker konnte nicht installiert werden: {0}",
"analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}",
+27 -10
View File
@@ -43,6 +43,13 @@
"autoContinueOnUsageLimitHint": "When a run stops because it hit the usage limit, automatically continue it once the 5-hour window resets. Also wakes the paused queue exactly at reset time instead of waiting for the next backstop check.",
"listHandlerMaxHandoffs": "List-handler handoff budget",
"listHandlerMaxHandoffsHint": "How many wait/merge handoffs one \"Let Claude handle it\" run may chain. 4 means two merge rounds. Raise it for runs meant to keep going for hours; it bounds a runaway chain, so it cannot be 0.",
"powerUseHeading": "PowerUse",
"powerUse": "Enable PowerUse",
"powerUseHint": "Spend the rest of the window: a list-handler run asks nothing, resolves conflicts itself, harvests new work, and keeps handing off for as long as its budget allows. Only affects lists that allowed PowerUse in their list settings.",
"powerUseWindow": "Only between",
"powerUseWindowPlaceholder": "HH:mm",
"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.",
"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.",
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
@@ -332,6 +339,7 @@
"failed": "Failed",
"roadblock": "Roadblock reported",
"openSession": "Open session",
"homeFolder": "Home (quick session)",
"browseForFolder": "Choose folder…",
"focusMode": "Focus",
"overviewMode": "Overview",
@@ -407,7 +415,7 @@
"findingsTracked": "Commit the .claudedo findings folder",
"findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.",
"powerUseAllowed": "Allow PowerUse for this list",
"powerUseAllowedHint": "Lets a list-handler run on this list go unattended when PowerUse is switched on in the usage monitor: it asks nothing, resolves conflicts itself, and harvests new work. Both switches are needed, so leaving PowerUse on globally never changes a list that hasn't opted in.",
"powerUseAllowedHint": "Lets a list-handler run on this list go unattended when PowerUse is switched on in Settings → Execution: it asks nothing, resolves conflicts itself, and harvests new work. Both switches are needed, so leaving PowerUse on globally never changes a list that hasn't opted in.",
"sectionIcon": "ICON",
"icon": "List icon",
"iconHint": "Shown next to the list name in the sidebar.",
@@ -510,18 +518,18 @@
"regenerate": "Regenerate",
"emptyStateHint": "No report for this range yet. Click “Generate”."
},
"cheatSheets": {
"title": "CHEAT SHEETS",
"windowTitle": "Cheat Sheets",
"pin": "Pin",
"pinTooltip": "Keep this window on top of the others",
"missing": "Cheat sheet {0} not found."
},
"usageMonitor": {
"title": "USAGE MONITOR",
"windowTitle": "Usage Monitor",
"noGauges": "No usage limits reported yet.",
"powerUse": "PowerUse",
"powerUseHint": "Spend the rest of the window: a list-handler run asks nothing, resolves conflicts itself, harvests new work, and keeps handing off for as long as its budget allows. Only affects lists that allowed PowerUse in their list settings.",
"powerUseWindow": "Only between",
"powerUseWindowPlaceholder": "HH:mm",
"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.",
"refresh": "Refresh now",
"refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.",
"refreshHint": "Refresh now — polled every 5 min while a task runs, otherwise every 15 min.",
"staleFormat": "Values stale (as of {0})",
"staleGateHint": "The gate does not apply while values are stale.",
"gateBlockedFormat": "Queue paused — {0}",
@@ -710,6 +718,9 @@
"abort": "Abort merge"
},
"controls": {
"markdown": {
"copy": "Copy command"
},
"datePicker": {
"today": "Today",
"tomorrow": "Tomorrow",
@@ -729,6 +740,13 @@
"worktrees": "Worktrees…",
"weeklyReport": "Weekly Report…",
"usageMonitor": "Usage Monitor…",
"cheatSheets": "Cheat Sheets",
"cheatSheetGit": "Git…",
"cheatSheetCommits": "Conventional Commits…",
"cheatSheetClaude": "Claude CLI…",
"cheatSheetClaudeConfig": "Claude configuration…",
"cheatSheetDotnet": "dotnet CLI…",
"cheatSheetPowershell": "PowerShell…",
"about": "About…",
"addRepos": "Add repos as lists…"
},
@@ -765,7 +783,6 @@
"loadFailed": "Couldn't load usage data: {0}",
"refreshFailed": "Couldn't refresh usage: {0}",
"thresholdSaveFailed": "Couldn't save the threshold: {0}",
"powerUseSaveFailed": "Couldn't save the PowerUse switch: {0}",
"installStarted": "Installing TokenTracker — watch the log strip for progress.",
"installFailed": "Couldn't install TokenTracker: {0}",
"analyticsRefreshFailed": "Couldn't refresh the analytics: {0}",
@@ -49,8 +49,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
[ObservableProperty] private string _verifyCommand = "";
// Per-list opt-in for PowerUse. A handler run on this list only goes unattended (no questions,
// harvests new work, longer handoff chain) when this AND the global switch in the usage monitor
// are both on. Off by default — the global switch alone must never change a list's behaviour.
// harvests new work, longer handoff chain) when this AND the global switch in Settings →
// Execution are both on. Off by default — the global switch alone must never change a list's behaviour.
[ObservableProperty] private bool _powerUseAllowed;
// Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab).
// Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry).
@@ -30,6 +30,15 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
// Cap on a list-handler run's wait<->merge handoff chain. 4 = two merge rounds (the long-standing
// default); raise it for runs meant to keep going for hours. Bounds a runaway chain, so never 0.
[ObservableProperty] private int _listHandlerMaxHandoffs = 4;
// PowerUse master switch: a handler run spends the rest of the window unattended. On its own it
// changes nothing — the run's LIST must also opt in (list settings → PowerUse).
[ObservableProperty] private bool _powerUseEnabled;
// Optional local-time window (HH:mm) narrowing when PowerUse applies; both blank = round the
// clock. Unparseable input is normalized to null on save, same as the worker does.
[ObservableProperty] private string? _powerUseWindowStart;
[ObservableProperty] private string? _powerUseWindowEnd;
// 5h-usage percentage at which a PowerUse handler session hands off. Clamped 50..99.
[ObservableProperty] private int _powerUseHandoffPct = 95;
// Newline-separated path prefixes excluded from the weekly report.
[ObservableProperty] private string _reportExcludedPaths = @"C:\Private";
// 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday.
@@ -1,5 +1,6 @@
using System.Linq;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
@@ -130,14 +131,6 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// 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;
@@ -156,11 +149,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
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);
General.PowerUseEnabled = dto.PowerUseEnabled;
General.PowerUseWindowStart = dto.PowerUseWindowStart;
General.PowerUseWindowEnd = dto.PowerUseWindowEnd;
General.PowerUseHandoffPct = dto.PowerUseHandoffPct;
_throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
@@ -228,11 +220,13 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
_throttleStages.SevenSoft,
_throttleStages.SevenHard,
General.AutoContinueOnUsageLimit,
_powerUseEnabled,
General.PowerUseEnabled,
General.ListHandlerMaxHandoffs,
_powerUseWindow.Start,
_powerUseWindow.End,
_powerUseWindow.HandoffPct);
// The worker stores an unparseable time as null; normalize here too so the box
// shows what was actually saved instead of a typo that looks like a live window.
PowerUseWindow.Normalize(General.PowerUseWindowStart),
PowerUseWindow.Normalize(General.PowerUseWindowEnd),
Math.Clamp(General.PowerUseHandoffPct, 50, 99));
await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
await OnlineInbox.SaveAsync();
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -101,77 +100,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
[ObservableProperty]
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
/// <summary>
/// PowerUse master switch. It lives here rather than in Settings because turning it on is a
/// "spend the rest of the window" decision, made while looking at how much window is left.
/// On its own it changes nothing — a list-handler run only goes unattended when its LIST also
/// opted in (list settings → PowerUse), so this can stay on without affecting other lists.
/// </summary>
[ObservableProperty] private bool _powerUseEnabled;
/// <summary>Optional local-time window (<c>HH:mm</c>) narrowing when PowerUse applies. Both
/// blank = round the clock. Committed on focus loss / Enter, not per keystroke.</summary>
[ObservableProperty] private string? _powerUseWindowStart;
[ObservableProperty] private string? _powerUseWindowEnd;
/// <summary>5h-usage percentage at which a PowerUse handler session should hand off.</summary>
[ObservableProperty] private int _powerUseHandoffPct = 95;
// Set while LoadAsync writes the stored value in, so seeding the property doesn't immediately
// write it straight back to the worker.
private bool _loadingPowerUse;
partial void OnPowerUseEnabledChanged(bool value)
{
if (_loadingPowerUse) return;
_ = SavePowerUse();
}
/// Read-modify-write, same discipline as SaveStagesAsync: never clobber a field this modal
/// doesn't own. All four PowerUse fields go in one write, so committing one box can't drop a
/// value the user just typed into a sibling box.
[RelayCommand]
private async Task SavePowerUse()
{
if (_loadingPowerUse) return;
try
{
var settings = await _worker.GetAppSettingsAsync();
if (settings is null)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
return;
}
// The worker stores an unparseable time as null; normalize here too so the box shows
// what was actually saved instead of a typo that looks like a live window.
var start = PowerUseWindow.Normalize(PowerUseWindowStart);
var end = PowerUseWindow.Normalize(PowerUseWindowEnd);
var pct = Math.Clamp(PowerUseHandoffPct, 50, 99);
await _worker.UpdateAppSettingsAsync(settings with
{
PowerUseEnabled = PowerUseEnabled,
PowerUseWindowStart = start,
PowerUseWindowEnd = end,
PowerUseHandoffPct = pct,
});
_loadingPowerUse = true;
try
{
PowerUseWindowStart = start;
PowerUseWindowEnd = end;
PowerUseHandoffPct = pct;
}
finally { _loadingPowerUse = false; }
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", ex.Message));
}
}
public bool IsStale => Snapshot?.IsStale == true;
public string? LastError => Snapshot?.LastError;
public bool IsGateBlocked => Snapshot?.IsGateBlocked == true;
@@ -221,20 +149,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
_worker.UsageUpdatedEvent -= OnUsageUpdated;
_worker.UsageUpdatedEvent += OnUsageUpdated;
var settings = await _worker.GetAppSettingsAsync();
if (settings is not null)
{
_loadingPowerUse = true;
try
{
PowerUseEnabled = settings.PowerUseEnabled;
PowerUseWindowStart = settings.PowerUseWindowStart;
PowerUseWindowEnd = settings.PowerUseWindowEnd;
PowerUseHandoffPct = settings.PowerUseHandoffPct;
}
finally { _loadingPowerUse = false; }
}
ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync();
}
@@ -14,19 +14,21 @@
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="14,0">
<Grid ColumnDefinitions="*,Auto,Auto,Auto" Margin="14,0">
<TextBlock Text="{TemplateBinding Title}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{DynamicResource FontSizeMono}"
LetterSpacing="1.4"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
<Button Name="PART_MaxButton" Grid.Column="1" Classes="icon-btn"
<ContentPresenter Grid.Column="1" Content="{TemplateBinding HeaderActions}"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<Button Name="PART_MaxButton" Grid.Column="2" Classes="icon-btn"
IsVisible="{TemplateBinding ShowMaximizeButton}"
VerticalAlignment="Center">
<PathIcon Name="PART_MaxIcon" Data="{StaticResource Icon.WinMax}" Width="10" Height="10"/>
</Button>
<Button Grid.Column="2" Classes="icon-btn" Content="✕"
<Button Grid.Column="3" Classes="icon-btn" Content="✕"
FontSize="{DynamicResource FontSizeBody}"
Command="{TemplateBinding CloseCommand}"
VerticalAlignment="Center"/>
@@ -17,6 +17,11 @@ public class ModalShell : ContentControl
public static readonly StyledProperty<object?> FooterProperty =
AvaloniaProperty.Register<ModalShell, object?>(nameof(Footer));
// Window-level actions rendered in the titlebar, left of maximise/close. Keeps a single
// always-visible action (e.g. the usage monitor's refresh) out of the body's vertical flow.
public static readonly StyledProperty<object?> HeaderActionsProperty =
AvaloniaProperty.Register<ModalShell, object?>(nameof(HeaderActions));
public static readonly StyledProperty<ICommand?> CloseCommandProperty =
AvaloniaProperty.Register<ModalShell, ICommand?>(nameof(CloseCommand));
@@ -27,6 +32,7 @@ public class ModalShell : ContentControl
public string? Title { get => GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
public object? Footer { get => GetValue(FooterProperty); set => SetValue(FooterProperty, value); }
public object? HeaderActions { get => GetValue(HeaderActionsProperty); set => SetValue(HeaderActionsProperty, value); }
public ICommand? CloseCommand { get => GetValue(CloseCommandProperty); set => SetValue(CloseCommandProperty, value); }
public bool ShowMaximizeButton { get => GetValue(ShowMaximizeButtonProperty); set => SetValue(ShowMaximizeButtonProperty, value); }
@@ -252,8 +252,8 @@
<TextBlock Text="{loc:Tr settings.general.autoContinueOnUsageLimitHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
<!-- PowerUse itself is toggled on the usage monitor; only its handoff budget is
typed here, because it also applies to a normal (interactive) handler run. -->
<!-- Outside the PowerUse section below on purpose: the handoff budget also bounds
a normal (interactive) handler run. -->
<StackPanel Spacing="4">
<TextBlock Classes="eyebrow" Text="{loc:Tr settings.general.listHandlerMaxHandoffs}"/>
<NumericUpDown Value="{Binding General.ListHandlerMaxHandoffs, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
@@ -264,6 +264,34 @@
</StackPanel>
</StackPanel>
<!-- PowerUse: a master switch plus its time window. Never sufficient on its own —
the run's list must also allow PowerUse in its list settings. -->
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.powerUseHeading}"/>
<CheckBox IsChecked="{Binding General.PowerUseEnabled, Mode=TwoWay}"
Content="{loc:Tr settings.general.powerUse}"/>
<TextBlock Text="{loc:Tr settings.general.powerUseHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0">
<TextBlock Classes="eyebrow" VerticalAlignment="Center"
Text="{loc:Tr settings.general.powerUseWindow}"/>
<TextBox Width="70" FontSize="12" Padding="6,2"
Watermark="{loc:Tr settings.general.powerUseWindowPlaceholder}"
Text="{Binding General.PowerUseWindowStart, Mode=TwoWay}"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="–"/>
<TextBox Width="70" FontSize="12" Padding="6,2"
Watermark="{loc:Tr settings.general.powerUseWindowPlaceholder}"
Text="{Binding General.PowerUseWindowEnd, Mode=TwoWay}"/>
<TextBlock Classes="eyebrow" VerticalAlignment="Center" Margin="12,0,0,0"
Text="{loc:Tr settings.general.powerUseHandoffPct}"/>
<NumericUpDown Width="80" FontSize="12" Padding="6,2" ShowButtonSpinner="False"
Minimum="50" Maximum="99" Increment="1" FormatString="0"
Value="{Binding General.PowerUseHandoffPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"/>
</StackPanel>
<TextBlock Text="{loc:Tr settings.general.powerUseWindowHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<!-- Throttle stages live on the usage-monitor gauges (drag + spinner per stage);
echoed read-only here so they aren't invisible on the tab that owns the gate. -->
<StackPanel Spacing="4">
@@ -19,6 +19,18 @@
</Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr modals.usageMonitor.title}" CloseCommand="{Binding CloseCommand}">
<!-- Refresh sits in the titlebar: the worker polls on a slow cadence to avoid the endpoint's
429s, so this is the way to get a number now — but it doesn't earn a row in the body. -->
<ctl:ModalShell.HeaderActions>
<StackPanel Orientation="Horizontal" Spacing="6">
<Ellipse Classes="spinner" Width="12" Height="12" VerticalAlignment="Center"
IsVisible="{Binding IsRefreshing}"/>
<Button Classes="icon-btn" Content="⟳" FontSize="{DynamicResource FontSizeBody}"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsRefreshing}"
ToolTip.Tip="{loc:Tr modals.usageMonitor.refreshHint}"/>
</StackPanel>
</ctl:ModalShell.HeaderActions>
<DockPanel>
<!-- Stale / gate bands -->
@@ -47,18 +59,6 @@
</Border>
</StackPanel>
<!-- Refresh: the worker polls on a slow cadence to avoid the endpoint's 429s -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8"
Margin="20,12,20,0" VerticalAlignment="Center">
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.refresh}"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsRefreshing}"/>
<Ellipse Classes="spinner" Width="14" Height="14" VerticalAlignment="Center"
IsVisible="{Binding IsRefreshing}"/>
<TextBlock Classes="meta" VerticalAlignment="Center"
Text="{loc:Tr modals.usageMonitor.refreshHint}"/>
</StackPanel>
<!-- Gauges -->
<ItemsControl DockPanel.Dock="Top" Margin="20,12,20,0" ItemsSource="{Binding GaugeRows}">
<ItemsControl.ItemsPanel>
@@ -134,42 +134,6 @@
Text="{loc:Tr modals.usageMonitor.noGauges}"
IsVisible="{Binding !GaugeRows.Count}"/>
<!-- PowerUse: sits under the gauges on purpose — "spend the rest of the window" is a
decision made while looking at how much window is left. Saves immediately on toggle. -->
<Border DockPanel.Dock="Top" Margin="20,12,20,0" Padding="12,10"
Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6">
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding PowerUseEnabled, Mode=TwoWay}"
Content="{loc:Tr modals.usageMonitor.powerUse}"/>
<TextBlock Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr modals.usageMonitor.powerUseHint}"/>
<!-- Window + handoff threshold. Same commit-on-blur/Enter discipline as the gauge
legend boxes: a hub write per keystroke would be one save per typed digit. -->
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0">
<TextBlock Classes="meta" VerticalAlignment="Center"
Text="{loc:Tr modals.usageMonitor.powerUseWindow}"/>
<TextBox Width="70" FontSize="12" Padding="6,2"
Watermark="{loc:Tr modals.usageMonitor.powerUseWindowPlaceholder}"
Text="{Binding PowerUseWindowStart, Mode=TwoWay}"
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="–"/>
<TextBox Width="70" FontSize="12" Padding="6,2"
Watermark="{loc:Tr modals.usageMonitor.powerUseWindowPlaceholder}"
Text="{Binding PowerUseWindowEnd, Mode=TwoWay}"
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Margin="12,0,0,0"
Text="{loc:Tr modals.usageMonitor.powerUseHandoffPct}"/>
<NumericUpDown Width="80" FontSize="12" Padding="6,2" ShowButtonSpinner="False"
Minimum="50" Maximum="99" Increment="1" FormatString="0"
Value="{Binding PowerUseHandoffPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
</StackPanel>
<TextBlock Classes="meta" TextWrapping="Wrap" Opacity="0.75"
Text="{loc:Tr modals.usageMonitor.powerUseWindowHint}"/>
</StackPanel>
</Border>
<!-- Period picker -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="20,12,20,0">
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.period}" VerticalAlignment="Center"/>
@@ -33,19 +33,4 @@ public partial class UsageMonitorModalView : Window
OnStageBoxCommit(sender, e);
e.Handled = true;
}
/// <summary>Persists the PowerUse window / handoff threshold on blur — a save per keystroke
/// would be one hub round-trip per typed digit.</summary>
private void OnPowerUseBoxCommit(object? sender, RoutedEventArgs e)
{
if (DataContext is not UsageMonitorModalViewModel vm) return;
if (vm.SavePowerUseCommand.CanExecute(null)) vm.SavePowerUseCommand.Execute(null);
}
private void OnPowerUseBoxKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
OnPowerUseBoxCommit(sender, e);
e.Handled = true;
}
}
@@ -607,8 +607,8 @@ public sealed class InteractiveLaunchSpecService
// The list-handler system prompt, plus the PowerUse addendum when this run is in PowerUse.
//
// PowerUse requires BOTH the global switch (AppSettingsEntity.PowerUseEnabled, toggled on the
// usage monitor) AND the list's own opt-in (ListConfigEntity.PowerUseAllowed). That is the whole
// PowerUse requires BOTH the global switch (AppSettingsEntity.PowerUseEnabled, Settings →
// Execution) AND the list's own opt-in (ListConfigEntity.PowerUseAllowed). That is the whole
// point of the two-key design: the global switch can be left on overnight without turning some
// unrelated list's handler run unattended, because a list that never opted in keeps the
// interactive prompt unchanged.
@@ -131,6 +131,38 @@ public class SettingsModalViewModelTests : IDisposable
Assert.Equal(77, worker.Saved.UsageGateSevenDayPct);
}
[Fact]
public async Task PowerUse_survives_a_load_edit_save_round_trip()
{
// PowerUse moved off the usage monitor onto this tab, so Settings is now its only writer.
// A binding that doesn't reach General silently resets the switch to the record default.
var worker = new FakeWorker
{
AppToReturn = DtoWith(80, 90) with
{
PowerUseEnabled = true,
PowerUseWindowStart = "18:00",
PowerUseWindowEnd = "06:00",
PowerUseHandoffPct = 88,
},
};
var vm = MakeVm(worker);
await vm.LoadAsync();
Assert.True(vm.General.PowerUseEnabled);
Assert.Equal("18:00", vm.General.PowerUseWindowStart);
Assert.Equal(88, vm.General.PowerUseHandoffPct);
// A typo in the window is normalized to null on save, not stored as a fake window.
vm.General.PowerUseWindowEnd = "not a time";
await vm.SaveCommand.ExecuteAsync(null);
Assert.True(worker.Saved!.PowerUseEnabled);
Assert.Equal("18:00", worker.Saved.PowerUseWindowStart);
Assert.Null(worker.Saved.PowerUseWindowEnd);
Assert.Equal(88, worker.Saved.PowerUseHandoffPct);
}
[Fact]
public async Task HasLinkedRepo_false_when_no_list_has_a_working_dir()
{
@@ -4,9 +4,8 @@ using Xunit;
namespace ClaudeDo.Worker.Tests.Hub;
/// The PowerUse window and handoff threshold are edited on the usage monitor, but Settings→General
/// rebuilds the whole DTO positionally on save. So the contract that matters is: whatever
/// GetAppSettings hands out, feeding it straight back must change nothing.
/// Settings rebuilds the whole AppSettings DTO positionally on save, so the contract that matters
/// is: whatever GetAppSettings hands out, feeding it straight back must change nothing.
public sealed class PowerUseSettingsHubTests : IDisposable
{
private readonly DbFixture _db = new();
@@ -72,8 +72,8 @@ public sealed class HandlerRestartCoordinatorTests : IDisposable
});
await ctx.SaveChangesAsync();
// Written straight to the row: AppSettingsRepository.UpdateAsync deliberately doesn't
// copy PowerUseEnabled (the usage monitor owns that toggle, not the settings modal).
// Written straight to the row so the fixture doesn't depend on the repository's
// clamping/normalizing copy block.
await new AppSettingsRepository(ctx).GetAsync(); // materializes the singleton row
var settings = await ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
settings.PowerUseEnabled = powerUseEnabled;
@@ -68,8 +68,8 @@ public sealed class HandlerUsageGuardTests : IDisposable
});
await ctx.SaveChangesAsync();
// Written straight to the row: AppSettingsRepository.UpdateAsync deliberately doesn't
// copy PowerUseEnabled (the usage monitor owns that toggle, not the settings modal).
// Written straight to the row so the fixture doesn't depend on the repository's
// clamping/normalizing copy block.
await new AppSettingsRepository(ctx).GetAsync();
var settings = await ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
settings.PowerUseEnabled = powerUseEnabled;