feat(ui): PowerUse-Toggle im Usage-Monitor, Opt-in pro Liste

Der Toggle sitzt unter den Gauges im Usage-Monitor, weil "das restliche Fenster
ausnutzen" eine Entscheidung ist, die man beim Blick auf das restliche Fenster
trifft. Er speichert sofort per Read-modify-write, gleiche Disziplin wie
SaveStagesAsync, damit er keine Felder ueberschreibt, die er nicht besitzt.

Zwei Stellen, die das Opt-in still ausgeschaltet haetten:
- WorkerHub.UpdateListConfig loescht die Config-Zeile, wenn alle Felder leer
  sind. PowerUseAllowed muss in diese Leer-Pruefung, sonst verschwindet das
  Opt-in sofort wieder, wenn es das einzige gesetzte Feld ist.
- SettingsModalViewModel baut das AppSettingsDto positional neu auf. Ohne
  Round-trip haette jedes Speichern der Settings PowerUseEnabled auf den
  Record-Default false zurueckgesetzt. Gleiches Muster wie _throttleStages.

Das Handoff-Budget ist als Spinner in Settings -> General editierbar, weil es
auch fuer normale (interaktive) Handler-Runs gilt und nicht an PowerUse haengt.
Das Addendum selbst ist ueber Settings -> Files anpassbar.
This commit is contained in:
Mika Kuns
2026-09-02 15:44:13 +02:00
parent 453d61ca58
commit 193752540b
11 changed files with 132 additions and 8 deletions
@@ -208,11 +208,13 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
catch { }
}
// verifyCommand and ticketProjectId are List-only fields owned by ListSettingsModalViewModel
// (not this editor, which is also reused for Task scope); the caller passes them through so the
// single UpdateListConfig call carries the full desired row instead of clobbering it.
// verifyCommand, ticketProjectId and powerUseAllowed are List-only fields owned by
// ListSettingsModalViewModel (not this editor, which is also reused for Task scope); the caller
// passes them through so the single UpdateListConfig call carries the full desired row instead
// of clobbering it. powerUseAllowed stays null-by-default for the same reason ticketProjectId
// does: a caller that doesn't own it must not revoke the list's opt-in by omission.
public async System.Threading.Tasks.Task SaveAsync(
string? verifyCommand = null, int? ticketProjectId = null)
string? verifyCommand = null, int? ticketProjectId = null, bool? powerUseAllowed = null)
{
if (TargetId is null) return;
var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
@@ -225,7 +227,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
if (_scope == AgentConfigScope.Task)
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission));
else
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId));
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId, powerUseAllowed));
}
private List<string>? SelectedSessionSkillNames()
@@ -45,6 +45,10 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// Optional post-merge verification command (build/test), run in WorkingDir after a merge
// 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.
[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).
[ObservableProperty] private string _icon = DefaultIcon;
@@ -110,6 +114,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
await Agent.LoadForListAsync(listId, ct);
var cfg = await _worker.GetListConfigAsync(listId);
VerifyCommand = cfg?.VerifyCommand ?? "";
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled
? await _worker.GetTicketSettingsAsync()
@@ -149,9 +154,12 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// Never null here, or an explicit unlink would silently fail to reach the worker.
var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
// Always explicit (never null) — this modal owns the checkbox, so unchecking it has to reach
// the worker as a real false rather than a "leave it alone".
await Agent.SaveAsync(
string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand,
ticketProjectId);
ticketProjectId,
PowerUseAllowed);
Saved = true;
CloseAction?.Invoke();
@@ -30,6 +30,7 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
public string RetryPromptPath { get; } = PromptFiles.PathFor(PromptKind.Retry);
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
public string PowerUsePromptPath { get; } = PromptFiles.PathFor(PromptKind.PowerUseAddendum);
public ObservableCollection<CustomizedPromptRowViewModel> CustomizedPrompts { get; } = new();
@@ -27,6 +27,9 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
// Off by default: auto-continues a usage-limit-failed task once the 5h window resets, and
// wakes a gate-blocked queue exactly at that reset time instead of only the 30s backstop.
[ObservableProperty] private bool _autoContinueOnUsageLimit;
// 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;
// Newline-separated path prefixes excluded from the weekly report.
[ObservableProperty] private string _reportExcludedPaths = @"C:\Private";
// 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday.
@@ -130,6 +130,10 @@ 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;
public async Task LoadAsync()
{
IsBusy = true;
@@ -147,6 +151,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
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;
_throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
@@ -213,7 +222,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
_throttleStages.FiveHard,
_throttleStages.SevenSoft,
_throttleStages.SevenHard,
General.AutoContinueOnUsageLimit);
General.AutoContinueOnUsageLimit,
_powerUseEnabled,
General.ListHandlerMaxHandoffs);
await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
await OnlineInbox.SaveAsync();
@@ -100,6 +100,45 @@ 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;
// 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;
_ = SavePowerUseAsync(value);
}
/// Read-modify-write, same discipline as SaveStagesAsync: never clobber a field this modal
/// doesn't own.
private async Task SavePowerUseAsync(bool value)
{
try
{
var settings = await _worker.GetAppSettingsAsync();
if (settings is null)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
return;
}
await _worker.UpdateAppSettingsAsync(settings with { PowerUseEnabled = value });
}
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;
@@ -148,6 +187,15 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
Snapshot = snapshot;
_worker.UsageUpdatedEvent -= OnUsageUpdated;
_worker.UsageUpdatedEvent += OnUsageUpdated;
var settings = await _worker.GetAppSettingsAsync();
if (settings is not null)
{
_loadingPowerUse = true;
try { PowerUseEnabled = settings.PowerUseEnabled; }
finally { _loadingPowerUse = false; }
}
ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync();
}
@@ -103,6 +103,13 @@
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding PowerUseAllowed, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.powerUseAllowed}"/>
<TextBlock Text="{loc:Tr modals.listSettings.powerUseAllowedHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding TicketsAvailable}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/>
<ComboBox ItemsSource="{Binding TicketProjects}"
@@ -251,6 +251,17 @@
Content="{loc:Tr settings.general.autoContinueOnUsageLimit}"/>
<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. -->
<StackPanel Spacing="4">
<TextBlock Classes="eyebrow" Text="{loc:Tr settings.general.listHandlerMaxHandoffs}"/>
<NumericUpDown Value="{Binding General.ListHandlerMaxHandoffs, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="1" Maximum="200" Increment="2" FormatString="0"
HorizontalAlignment="Left" Width="140"/>
<TextBlock Text="{loc:Tr settings.general.listHandlerMaxHandoffsHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
<!-- Throttle stages live on the usage-monitor gauges (drag + spinner per stage);
@@ -433,7 +444,7 @@
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="{loc:Tr settings.files.promptsSection}"/>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="120,*,Auto" RowSpacing="8">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="120,*,Auto" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.files.systemPrompt}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono" Text="{Binding Files.SystemPromptPath}" VerticalAlignment="Center"/>
<Button Classes="btn" Grid.Row="0" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}"
@@ -458,6 +469,10 @@
<TextBlock Grid.Row="5" Grid.Column="1" Classes="path-mono" Text="{Binding Files.WeeklyReportPromptPath}" VerticalAlignment="Center"/>
<Button Classes="btn" Grid.Row="5" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}"
Command="{Binding Files.ViewPromptCommand}" CommandParameter="WeeklyReport"/>
<TextBlock Grid.Row="6" Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.files.powerUsePrompt}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="6" Grid.Column="1" Classes="path-mono" Text="{Binding Files.PowerUsePromptPath}" VerticalAlignment="Center"/>
<Button Classes="btn" Grid.Row="6" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}"
Command="{Binding Files.ViewPromptCommand}" CommandParameter="PowerUseAddendum"/>
</Grid>
</StackPanel>
<StackPanel Spacing="6" IsVisible="{Binding Files.ViewedKindName, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
@@ -134,6 +134,19 @@
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}"/>
</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"/>