Merge branch 'main' into worktree-diff-side-by-side

# Conflicts:
#	src/ClaudeDo.Ui/CLAUDE.md
This commit is contained in:
mika kuns
2026-08-07 11:16:49 +02:00
64 changed files with 4270 additions and 493 deletions
@@ -19,6 +19,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public PrimeClaudeTabViewModel Prime { get; }
public OnlineInboxSettingsViewModel OnlineInbox { get; }
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false;
[ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy;
@@ -48,6 +51,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
}
// 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);
public async Task LoadAsync()
{
IsBusy = true;
@@ -64,6 +71,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.MaxParallelExecutions = dto.MaxParallelExecutions;
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
_throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
@@ -115,7 +125,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.ModelPresetDtos(),
General.UsageGateFiveHourPct,
General.UsageGateSevenDayPct,
General.MaxTurnsCeiling);
General.MaxTurnsCeiling,
_throttleStages.FiveSoft,
_throttleStages.FiveHard,
_throttleStages.SevenSoft,
_throttleStages.SevenHard);
await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
await OnlineInbox.SaveAsync();
@@ -20,7 +20,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
public event Action<string>? ErrorReported;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(GaugeRows))]
[NotifyPropertyChangedFor(nameof(IsStale))]
[NotifyPropertyChangedFor(nameof(LastError))]
[NotifyPropertyChangedFor(nameof(IsGateBlocked))]
@@ -53,8 +52,8 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
public IReadOnlyList<UsageGaugeRowViewModel> GaugeRows =>
Snapshot is null ? Array.Empty<UsageGaugeRowViewModel>() : Snapshot.Limits.Select(BuildGaugeRow).ToList();
[ObservableProperty]
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
public bool IsStale => Snapshot?.IsStale == true;
public string? LastError => Snapshot?.LastError;
@@ -83,13 +82,30 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
CloseAction?.Invoke();
}
/// <summary>
/// Starts the load without blocking the caller, so the host can show the window right away and
/// let it fill in behind the busy spinner. The first load per worker process pays a full scan of
/// <c>~/.claude/projects</c> (hundreds of MB of transcripts) — awaiting it before showing the
/// window made the usage pill look like it swallowed the click.
/// </summary>
public void BeginLoad() => _ = LoadAsync();
public async Task LoadAsync()
{
Snapshot = await _worker.GetUsageSnapshotAsync();
_worker.UsageUpdatedEvent -= OnUsageUpdated;
_worker.UsageUpdatedEvent += OnUsageUpdated;
ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync();
IsBusy = true;
try
{
Snapshot = await _worker.GetUsageSnapshotAsync();
_worker.UsageUpdatedEvent -= OnUsageUpdated;
_worker.UsageUpdatedEvent += OnUsageUpdated;
ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync();
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message));
}
finally { IsBusy = false; }
}
private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot;
@@ -173,6 +189,99 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
finally { IsBusy = false; }
}
partial void OnSnapshotChanged(UsageSnapshotDto? value) => SyncGaugeRows();
/// <summary>
/// Folds a fresh snapshot into the existing rows instead of rebuilding them, so a poll landing
/// while the user works the markers doesn't swap the instances out from under the drag.
/// </summary>
private void SyncGaugeRows()
{
var limits = Snapshot?.Limits ?? (IReadOnlyList<UsageLimitDto>)Array.Empty<UsageLimitDto>();
var existing = GaugeRows.ToDictionary(r => r.Key);
var rows = new List<UsageGaugeRowViewModel>(limits.Count);
foreach (var limit in limits)
{
var key = GaugeKey(limit);
var bucket = GaugeBucket(limit);
var (soft, hard, gate) = StagesFor(bucket);
var label = BuildGaugeLabel(limit);
if (existing.TryGetValue(key, out var row))
{
row.Update(label, limit.Percent, limit.Severity, limit.ResetsAt, soft, hard, gate);
rows.Add(row);
}
else
{
rows.Add(new UsageGaugeRowViewModel(
key, bucket, label, limit.Percent, limit.Severity, limit.ResetsAt,
soft, hard, gate, SaveStagesAsync));
}
}
if (!rows.SequenceEqual(GaugeRows)) GaugeRows = rows;
}
// Scoped weekly buckets are plan-dependent and share no settings row, so they stay read-only.
private static string? GaugeBucket(UsageLimitDto limit) => limit.Kind switch
{
"session" => "five_hour",
"weekly_all" => "seven_day",
_ => null,
};
private static string GaugeKey(UsageLimitDto limit) =>
limit.Kind == "weekly_scoped" ? $"weekly_scoped:{limit.ScopeModelDisplayName}" : limit.Kind;
private (int? Soft, int? Hard, int? Gate) StagesFor(string? bucket) => (bucket, Snapshot) switch
{
("five_hour", { } s) => (s.ThrottleFiveHourSoftPct, s.ThrottleFiveHourHardPct, s.FiveHourThresholdPct),
("seven_day", { } s) => (s.ThrottleSevenDaySoftPct, s.ThrottleSevenDayHardPct, s.SevenDayThresholdPct),
_ => (null, null, null),
};
/// <summary>
/// Persists one gauge's stages after a drag. Read-modify-write against the current settings, so
/// this never clobbers a field the usage monitor doesn't own.
/// </summary>
private async Task SaveStagesAsync(UsageGaugeRowViewModel row)
{
if (row.Bucket is null || row.SoftPct is not { } soft || row.HardPct is not { } hard || row.GatePct is not { } gate)
return;
try
{
var settings = await _worker.GetAppSettingsAsync();
if (settings is null)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
return;
}
var updated = row.Bucket == "five_hour"
? settings with
{
UsageThrottleFiveHourSoftPct = soft,
UsageThrottleFiveHourHardPct = hard,
UsageGateFiveHourPct = gate,
}
: settings with
{
UsageThrottleSevenDaySoftPct = soft,
UsageThrottleSevenDayHardPct = hard,
UsageGateSevenDayPct = gate,
};
await _worker.UpdateAppSettingsAsync(updated);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", ex.Message));
}
}
private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch
{
"session" => Loc.T("modals.usageMonitor.gaugeSession"),
@@ -182,17 +291,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
_ => limit.Kind,
};
private UsageGaugeRowViewModel BuildGaugeRow(UsageLimitDto limit)
{
int? threshold = limit.Kind switch
{
"session" => Snapshot?.FiveHourThresholdPct,
"weekly_all" => Snapshot?.SevenDayThresholdPct,
_ => null,
};
return new UsageGaugeRowViewModel(BuildGaugeLabel(limit), limit.Percent, limit.Severity, limit.ResetsAt, threshold);
}
private static IReadOnlyList<ModelUsageDisplayRow> BuildModelDisplayRows(IReadOnlyList<ModelUsageRowDto> rows)
{
var built = new List<ModelUsageDisplayRow>();
@@ -222,22 +320,115 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
}
}
public sealed record UsageGaugeRowViewModel(
string Label,
double Percent,
string Severity,
DateTimeOffset? ResetsAt,
int? ThresholdPercent)
/// <summary>
/// One usage gauge. The two real buckets (5h session, 7d week) carry their three stage thresholds
/// and are adjustable by dragging; plan-dependent scoped buckets render as a plain bar.
/// </summary>
public sealed partial class UsageGaugeRowViewModel : ObservableObject
{
private readonly Func<UsageGaugeRowViewModel, Task>? _commit;
public UsageGaugeRowViewModel(
string key,
string? bucket,
string label,
double percent,
string severity,
DateTimeOffset? resetsAt,
int? softPct,
int? hardPct,
int? gatePct,
Func<UsageGaugeRowViewModel, Task>? commit = null)
{
Key = key;
Bucket = bucket;
_label = label;
_percent = percent;
_severity = severity;
_resetsAt = resetsAt;
_softPct = softPct;
_hardPct = hardPct;
_gatePct = gatePct;
_commit = commit;
}
/// <summary>Identity across snapshot updates, so a live poll updates rows instead of replacing them.</summary>
public string Key { get; }
/// <summary>Which settings bucket a drag writes to: <c>five_hour</c>, <c>seven_day</c>, or null.</summary>
public string? Bucket { get; }
[ObservableProperty] private string _label;
[ObservableProperty] private double _percent;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsWarnSeverity))]
private string _severity;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ResetText))]
private DateTimeOffset? _resetsAt;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
private int? _softPct;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
private int? _hardPct;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
private int? _gatePct;
public bool IsAdjustable => Bucket is not null && SoftPct is not null && HardPct is not null && GatePct is not null;
public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase);
public string ResetText => ResetsAt is { } r ? Loc.T("modals.usageMonitor.resetIn", FormatRemaining(r)) : "";
// Matches the gauge card's inner track width in the view (240 card width - 12*2 padding).
private const double GaugeTrackWidthPx = 216;
/// <summary>Live values from a fresh snapshot, without replacing the row instance mid-view.</summary>
public void Update(string label, double percent, string severity, DateTimeOffset? resetsAt,
int? softPct, int? hardPct, int? gatePct)
{
Label = label;
Percent = percent;
Severity = severity;
ResetsAt = resetsAt;
SoftPct = softPct;
HardPct = hardPct;
GatePct = gatePct;
}
public double ThresholdMarkerLeftPx =>
ThresholdPercent is { } t ? GaugeTrackWidthPx * Math.Clamp(t, 0, 100) / 100.0 : 0;
/// <summary>Raised by the gauge control when a drag ends — that is when the value is persisted.</summary>
[RelayCommand]
private Task Commit() => _commit?.Invoke(this) ?? Task.CompletedTask;
// One per legend input box. A typed value goes through the same clamp as a dragged one, so a box
// can't invert the order — and only the edited stage moves, never its neighbours.
[RelayCommand] private Task CommitSoft() => CommitStage(UsageThresholdDrag.Stage.Soft);
[RelayCommand] private Task CommitHard() => CommitStage(UsageThresholdDrag.Stage.Hard);
[RelayCommand] private Task CommitGate() => CommitStage(UsageThresholdDrag.Stage.Gate);
private Task CommitStage(UsageThresholdDrag.Stage stage)
{
if (!IsAdjustable) return Task.CompletedTask;
var edited = stage switch
{
UsageThresholdDrag.Stage.Soft => SoftPct!.Value,
UsageThresholdDrag.Stage.Hard => HardPct!.Value,
_ => GatePct!.Value,
};
var (soft, hard, gate) = UsageThresholdDrag.Apply(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, edited);
SoftPct = soft;
HardPct = hard;
GatePct = gate;
return Commit();
}
private static string FormatRemaining(DateTimeOffset resetsAt)
{
@@ -251,6 +442,54 @@ public sealed record UsageGaugeRowViewModel(
}
}
/// <summary>
/// Drag math for the gauge stage markers, kept out of the control so it can be tested directly:
/// every stage stays inside 0..100 and never crosses a neighbour (soft ≤ hard ≤ gate). A neighbour
/// at 0 means "that stage is off" and therefore does not constrain anything.
/// </summary>
public static class UsageThresholdDrag
{
public enum Stage { Soft, Hard, Gate }
/// <summary>Pointer reach for grabbing a marker, as a share of the bar width.</summary>
public static Stage? Nearest(int soft, int hard, int gate, double percent, double tolerancePercent)
{
Stage? best = null;
var bestDistance = double.MaxValue;
foreach (var (stage, value) in new[] { (Stage.Soft, soft), (Stage.Hard, hard), (Stage.Gate, gate) })
{
var distance = Math.Abs(percent - value);
if (distance > tolerancePercent || distance >= bestDistance) continue;
best = stage;
bestDistance = distance;
}
return best;
}
public static (int Soft, int Hard, int Gate) Apply(int soft, int hard, int gate, Stage stage, double rawPercent)
{
var value = (int)Math.Round(Math.Clamp(rawPercent, 0, 100));
return stage switch
{
Stage.Soft => (ClampRange(value, 0, UpperBound(hard, gate)), hard, gate),
Stage.Hard => (soft, ClampRange(value, soft, UpperBound(gate, 100)), gate),
Stage.Gate => (soft, hard, ClampRange(value, Math.Max(soft, hard), 100)),
_ => (soft, hard, gate),
};
}
// A neighbour of 0 is switched off and must not pin the dragged marker to 0.
private static int UpperBound(int nearest, int fallback) =>
nearest > 0 ? nearest : (fallback > 0 ? fallback : 100);
// An already-inconsistent stored config (min above max) must not throw mid-drag.
private static int ClampRange(int value, int min, int max) =>
max < min ? max : Math.Clamp(value, min, max);
}
public sealed record ModelUsageDisplayRow(
string Model,
long ClaudeDoInputTokens,