feat(ui): add usage pill to footer and mission control header

Adds the IWorkerClient/WorkerClient usage surface (GetUsageSnapshot,
GetModelUsage, GetTaskUsage, UsageUpdated event) and a shared
UsagePillViewModel hosted once in IslandsShellViewModel (footer) and
once in MissionControlViewModel (header), showing "5h X% · 7d Y%"
with warn/blocked/stale states via existing design tokens. The
OpenMonitorCommand is wired but currently a no-op, pending the usage
monitor modal.
This commit is contained in:
mika kuns
2026-08-05 13:16:29 +02:00
parent 6e2158d157
commit 7cbd4e66ae
16 changed files with 414 additions and 4 deletions
+4 -1
View File
@@ -159,10 +159,13 @@ sealed class Program
sp, sp,
sp.GetRequiredService<INotesApi>(), sp.GetRequiredService<INotesApi>(),
sp.GetRequiredService<IMergeCoordinator>())); sp.GetRequiredService<IMergeCoordinator>()));
sc.AddSingleton<UsagePillViewModel>(sp =>
new UsagePillViewModel(sp.GetRequiredService<IWorkerClient>()));
sc.AddSingleton<MissionControlViewModel>(sp => sc.AddSingleton<MissionControlViewModel>(sp =>
new MissionControlViewModel( new MissionControlViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>())); sp.GetRequiredService<IWorkerClient>(),
sp.GetRequiredService<UsagePillViewModel>()));
sc.AddSingleton<IslandsShellViewModel>(sp => sc.AddSingleton<IslandsShellViewModel>(sp =>
{ {
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp); var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
+16
View File
@@ -562,5 +562,21 @@
"listSettings": { "untitled": "Unbenannt" }, "listSettings": { "untitled": "Unbenannt" },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt." }, "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt." },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" } "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
},
"usage": {
"pill": {
"empty": "Nutzung ",
"bothFormat": "5h {0}% · 7d {1}%",
"fiveHourFormat": "5h {0}%",
"sevenDayFormat": "7d {0}%",
"fiveHourLabel": "5h",
"sevenDayLabel": "7d",
"resetIn": "{0}: Reset in {1}",
"durationHoursMinutes": "{0} h {1} m",
"durationMinutes": "{0} m",
"blockedReason": "Blockiert: {0}",
"stale": "veraltet (Stand {0})",
"lastError": "Letzter Fehler: {0}"
}
} }
} }
+16
View File
@@ -562,5 +562,21 @@
"listSettings": { "untitled": "Untitled" }, "listSettings": { "untitled": "Untitled" },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done." }, "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done." },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" } "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
},
"usage": {
"pill": {
"empty": "Usage ",
"bothFormat": "5h {0}% · 7d {1}%",
"fiveHourFormat": "5h {0}%",
"sevenDayFormat": "7d {0}%",
"fiveHourLabel": "5h",
"sevenDayLabel": "7d",
"resetIn": "{0}: Reset in {1}",
"durationHoursMinutes": "{0} h {1} m",
"durationMinutes": "{0} m",
"blockedReason": "Blocked: {0}",
"stale": "stale (as of {0})",
"lastError": "Last error: {0}"
}
} }
} }
@@ -139,4 +139,10 @@ public interface IWorkerClient : INotifyPropertyChanged
Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input); Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input);
Task SetOnlineInboxAuthAsync(string refreshToken); Task SetOnlineInboxAuthAsync(string refreshToken);
Task ClearOnlineInboxAuthAsync(); Task ClearOnlineInboxAuthAsync();
/// <summary>Raised whenever the worker's usage poller ticks (success or failure).</summary>
event Action<UsageSnapshotDto>? UsageUpdatedEvent;
Task<UsageSnapshotDto?> GetUsageSnapshotAsync();
Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsageAsync(DateOnly from, DateOnly to);
Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsageAsync(DateOnly from, DateOnly to);
} }
+57
View File
@@ -61,6 +61,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string>? RefineStartedEvent; public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent; public event Action<string, bool, string?>? RefineFinishedEvent;
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
public event Action<string, string>? PlanningMergeStartedEvent; public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent; public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent; public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
@@ -202,6 +204,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => RefineStartedEvent?.Invoke(id))); Dispatcher.UIThread.Post(() => RefineStartedEvent?.Invoke(id)));
_hub.On<string, bool, string?>("RefineFinished", (id, ok, err) => _hub.On<string, bool, string?>("RefineFinished", (id, ok, err) =>
Dispatcher.UIThread.Post(() => RefineFinishedEvent?.Invoke(id, ok, err))); Dispatcher.UIThread.Post(() => RefineFinishedEvent?.Invoke(id, ok, err)));
_hub.On<UsageSnapshotDto>("UsageUpdated", snapshot =>
Dispatcher.UIThread.Post(() => UsageUpdatedEvent?.Invoke(snapshot)));
} }
public Task StartAsync() public Task StartAsync()
@@ -574,6 +579,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task ClearOnlineInboxAuthAsync() public async Task ClearOnlineInboxAuthAsync()
=> await _hub.InvokeAsync("ClearOnlineInboxAuth"); => await _hub.InvokeAsync("ClearOnlineInboxAuth");
public Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
=> TryInvokeAsync<UsageSnapshotDto>("GetUsageSnapshot");
public async Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsageAsync(DateOnly from, DateOnly to)
=> await TryInvokeAsync<List<ModelUsageRowDto>>("GetModelUsage", from, to) ?? [];
public async Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsageAsync(DateOnly from, DateOnly to)
=> await TryInvokeAsync<List<TaskUsageRowDto>>("GetTaskUsage", from, to) ?? [];
// IWorkerClient explicit implementations (drop typed return values) // IWorkerClient explicit implementations (drop typed return values)
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct) async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
=> await StartPlanningSessionAsync(taskId, ct); => await StartPlanningSessionAsync(taskId, ct);
@@ -676,3 +690,46 @@ public sealed record OnlineInboxConfigInputDto(
string ClientId, string ClientId,
string Scopes, string Scopes,
string RedirectUri); string RedirectUri);
public sealed record UsageLimitDto(
string Kind,
string Group,
double Percent,
string Severity,
DateTimeOffset? ResetsAt,
string? ScopeModelDisplayName,
bool IsActive);
public sealed record UsageSnapshotDto(
double? FiveHourPercent,
DateTimeOffset? FiveHourResetsAt,
double? SevenDayPercent,
DateTimeOffset? SevenDayResetsAt,
IReadOnlyList<UsageLimitDto> Limits,
int FiveHourThresholdPct,
int SevenDayThresholdPct,
bool IsGateBlocked,
string? GateReason,
DateTime? FetchedAtUtc,
bool IsStale,
string? LastError);
public sealed record ModelUsageRowDto(
DateOnly Date,
string Model,
string Scope,
long InputTokens,
long OutputTokens,
long CacheReadTokens,
long CacheCreationTokens,
int Messages);
public sealed record TaskUsageRowDto(
string TaskId,
string TaskTitle,
string ListId,
string ListName,
string? Model,
int Runs,
long TokensIn,
long TokensOut);
@@ -21,6 +21,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
public DetailsIslandViewModel? Details { get; } public DetailsIslandViewModel? Details { get; }
public IWorkerClient? Worker { get; } public IWorkerClient? Worker { get; }
public MissionControlViewModel? MissionControl { get; } public MissionControlViewModel? MissionControl { get; }
public UsagePillViewModel? UsagePill { get; }
public UpdateCheckService UpdateCheck => _updateCheck; public UpdateCheckService UpdateCheck => _updateCheck;
public string ConnectionText => public string ConnectionText =>
@@ -208,10 +209,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Func<WeeklyReportModalViewModel> weeklyReportVmFactory, Func<WeeklyReportModalViewModel> weeklyReportVmFactory,
Func<MergeModalViewModel> mergeVmFactory, Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory, Func<RepoImportModalViewModel> repoImportVmFactory,
MissionControlViewModel missionControl) MissionControlViewModel missionControl,
UsagePillViewModel usagePill)
{ {
Lists = lists; Tasks = tasks; Details = details; Worker = worker; Lists = lists; Tasks = tasks; Details = details; Worker = worker;
MissionControl = missionControl; MissionControl = missionControl;
UsagePill = usagePill;
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id); MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null); MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
MissionControl.ErrorReported += FlashFooterError; MissionControl.ErrorReported += FlashFooterError;
@@ -54,10 +54,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new(); public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
public bool HasQueued => Queued.Count > 0; public bool HasQueued => Queued.Count > 0;
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker) public UsagePillViewModel UsagePill { get; }
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker; _worker = worker;
UsagePill = usagePill;
ConPtySessions.CollectionChanged += OnConPtySessionsChanged; ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
Panes.CollectionChanged += OnPanesChanged; Panes.CollectionChanged += OnPanesChanged;
@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels;
/// <summary>Backs the footer/Mission-Control-header usage pill. One instance is shared by both hosts.</summary>
public sealed partial class UsagePillViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
[ObservableProperty]
private UsageSnapshotDto? _snapshot;
/// <summary>Host wires this to open the usage-monitor modal (not built yet — the command is a no-op until then).</summary>
public event Action? OpenMonitorRequested;
public UsagePillViewModel(IWorkerClient worker)
{
_worker = worker;
_worker.UsageUpdatedEvent += OnUsageUpdated;
_ = LoadAsync();
}
private async System.Threading.Tasks.Task LoadAsync()
{
var snapshot = await _worker.GetUsageSnapshotAsync();
if (snapshot is not null)
Snapshot = snapshot;
}
private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot;
partial void OnSnapshotChanged(UsageSnapshotDto? value)
{
OnPropertyChanged(nameof(Text));
OnPropertyChanged(nameof(IsWarn));
OnPropertyChanged(nameof(IsBlocked));
OnPropertyChanged(nameof(IsStale));
OnPropertyChanged(nameof(Tooltip));
OnPropertyChanged(nameof(ShowNormalDot));
OnPropertyChanged(nameof(ShowWarnDot));
OnPropertyChanged(nameof(ShowStaleDot));
}
public string Text => BuildText(Snapshot);
public bool IsBlocked => Snapshot?.IsGateBlocked == true;
public bool IsStale => Snapshot?.IsStale == true;
public bool IsWarn => Snapshot is { } s &&
((s.FiveHourPercent is { } five && five >= s.FiveHourThresholdPct - 10) ||
(s.SevenDayPercent is { } seven && seven >= s.SevenDayThresholdPct - 10));
// Visual dot priority: blocked > stale > warn > normal — mirrors the connection pill's
// mutually-exclusive dot Ellipses, since Blocked/Stale/Warn aren't mutually exclusive states.
public bool ShowNormalDot => !IsBlocked && !IsStale && !IsWarn;
public bool ShowWarnDot => IsWarn && !IsBlocked && !IsStale;
public bool ShowStaleDot => IsStale && !IsBlocked;
public string Tooltip => BuildTooltip(Snapshot);
[RelayCommand]
private void OpenMonitor() => OpenMonitorRequested?.Invoke();
private static string BuildText(UsageSnapshotDto? s)
{
if (s is null) return Loc.T("usage.pill.empty");
var five = s.FiveHourPercent;
var seven = s.SevenDayPercent;
if (five is not null && seven is not null)
return Loc.T("usage.pill.bothFormat", FormatPct(five.Value), FormatPct(seven.Value));
if (five is not null)
return Loc.T("usage.pill.fiveHourFormat", FormatPct(five.Value));
if (seven is not null)
return Loc.T("usage.pill.sevenDayFormat", FormatPct(seven.Value));
return Loc.T("usage.pill.empty");
}
private static string FormatPct(double v) => Math.Round(v).ToString("0");
private static string BuildTooltip(UsageSnapshotDto? s)
{
if (s is null) return Loc.T("usage.pill.empty");
var lines = new List<string>();
if (s.FiveHourResetsAt is { } fiveReset)
lines.Add(Loc.T("usage.pill.resetIn", Loc.T("usage.pill.fiveHourLabel"), FormatRemaining(fiveReset)));
if (s.SevenDayResetsAt is { } sevenReset)
lines.Add(Loc.T("usage.pill.resetIn", Loc.T("usage.pill.sevenDayLabel"), FormatRemaining(sevenReset)));
if (s.IsGateBlocked && !string.IsNullOrEmpty(s.GateReason))
lines.Add(Loc.T("usage.pill.blockedReason", s.GateReason));
if (s.IsStale)
{
var stamp = s.FetchedAtUtc?.ToLocalTime().ToString("HH:mm") ?? "?";
lines.Add(Loc.T("usage.pill.stale", stamp));
if (!string.IsNullOrEmpty(s.LastError))
lines.Add(Loc.T("usage.pill.lastError", s.LastError));
}
return lines.Count > 0 ? string.Join(Environment.NewLine, lines) : Loc.T("usage.pill.empty");
}
private static string FormatRemaining(DateTimeOffset resetsAt)
{
var remaining = resetsAt - DateTimeOffset.UtcNow;
if (remaining < TimeSpan.Zero) remaining = TimeSpan.Zero;
var hours = (int)remaining.TotalHours;
var minutes = remaining.Minutes;
return hours > 0
? Loc.T("usage.pill.durationHoursMinutes", hours, minutes)
: Loc.T("usage.pill.durationMinutes", minutes);
}
}
@@ -0,0 +1,25 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
x:Class="ClaudeDo.Ui.Views.Controls.UsagePill"
x:DataType="vm:UsagePillViewModel">
<Button Command="{Binding OpenMonitorCommand}"
Background="Transparent" BorderThickness="0" Padding="0"
Cursor="Hand" VerticalAlignment="Center"
ToolTip.Tip="{Binding Tooltip}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Width="7" Height="7" Fill="{DynamicResource TextDimBrush}"
IsVisible="{Binding ShowNormalDot}"/>
<Ellipse Width="7" Height="7" Fill="{DynamicResource StatusReviewBrush}"
IsVisible="{Binding ShowWarnDot}"/>
<Ellipse Width="7" Height="7" Fill="{DynamicResource StatusErrorBrush}"
IsVisible="{Binding IsBlocked}"/>
<Ellipse Width="7" Height="7" Fill="{DynamicResource TextMuteBrush}"
IsVisible="{Binding ShowStaleDot}"/>
<TextBlock Classes="eyebrow"
Text="{Binding Text, Converter={StaticResource UpperCase}}"
LetterSpacing="1.4"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views.Controls;
public partial class UsagePill : UserControl
{
public UsagePill() => InitializeComponent();
}
+7
View File
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels" xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:islands="using:ClaudeDo.Ui.Views.Islands" xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
xmlns:controls="using:ClaudeDo.Ui.Views.Controls"
xmlns:converters="using:ClaudeDo.Ui.Converters" xmlns:converters="using:ClaudeDo.Ui.Converters"
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.MainWindow" x:Class="ClaudeDo.Ui.Views.MainWindow"
@@ -184,6 +185,12 @@
</StackPanel> </StackPanel>
</Button> </Button>
<!-- Left: usage pill (click opens the usage monitor, wired in a later subtask) -->
<controls:UsagePill DockPanel.Dock="Left"
DataContext="{Binding UsagePill}"
Margin="14,0,0,0"
VerticalAlignment="Center"/>
<!-- Right: worker log line — click to open the Log Visualizer overlay --> <!-- Right: worker log line — click to open the Log Visualizer overlay -->
<Button DockPanel.Dock="Right" <Button DockPanel.Dock="Right"
Command="{Binding OpenLogVisualizerCommand}" Command="{Binding OpenLogVisualizerCommand}"
@@ -3,6 +3,7 @@
xmlns:vm="using:ClaudeDo.Ui.ViewModels" xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl" xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl"
xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl" xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl"
xmlns:controls="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:MissionControlViewModel" x:DataType="vm:MissionControlViewModel"
x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView"> x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView">
@@ -25,6 +26,9 @@
LetterSpacing="1.4" VerticalAlignment="Center" /> LetterSpacing="1.4" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Center"> VerticalAlignment="Center">
<controls:UsagePill DataContext="{Binding UsagePill}"
Margin="0,0,4,0"
VerticalAlignment="Center"/>
<Button Classes="icon-btn" <Button Classes="icon-btn"
Click="OnNewSessionClicked" Click="OnNewSessionClicked"
ToolTip.Tip="{loc:Tr missionControl.newSession}"> ToolTip.Tip="{loc:Tr missionControl.newSession}">
@@ -36,6 +36,7 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<string>? PlanningMergeAbortedEvent; public event Action<string>? PlanningMergeAbortedEvent;
public event Action<string>? PlanningCompletedEvent; public event Action<string>? PlanningCompletedEvent;
public event Action<PrimeFiredEvent>? PrimeFired; public event Action<PrimeFiredEvent>? PrimeFired;
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
#pragma warning restore CS0067 #pragma warning restore CS0067
public int ClearMyDayCalls { get; private set; } public int ClearMyDayCalls { get; private set; }
@@ -151,5 +152,12 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask; public virtual Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
public virtual Task ClearOnlineInboxAuthAsync() => Task.CompletedTask; public virtual Task ClearOnlineInboxAuthAsync() => Task.CompletedTask;
public virtual Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult<UsageSnapshotDto?>(null);
public virtual Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsageAsync(DateOnly from, DateOnly to)
=> Task.FromResult<IReadOnlyList<ModelUsageRowDto>>(Array.Empty<ModelUsageRowDto>());
public virtual Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsageAsync(DateOnly from, DateOnly to)
=> Task.FromResult<IReadOnlyList<TaskUsageRowDto>>(Array.Empty<TaskUsageRowDto>());
public void RaiseUsageUpdated(UsageSnapshotDto snapshot) => UsageUpdatedEvent?.Invoke(snapshot);
protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
} }
@@ -45,7 +45,7 @@ public class MissionControlViewModelTests : IDisposable
private sealed class FakeWorker : StubWorkerClient { } private sealed class FakeWorker : StubWorkerClient { }
private MissionControlViewModel BuildVm(StubWorkerClient worker) private MissionControlViewModel BuildVm(StubWorkerClient worker)
=> new MissionControlViewModel(new TestDbFactory(NewContext), worker); => new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker));
// ── acceptance criterion (a): TaskStarted must NOT add a pane ────────────── // ── acceptance criterion (a): TaskStarted must NOT add a pane ──────────────
@@ -0,0 +1,128 @@
using System;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class UsagePillViewModelTests
{
private sealed class FakeWorker : StubWorkerClient
{
public UsageSnapshotDto? InitialSnapshot;
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult(InitialSnapshot);
}
private static UsageSnapshotDto Snapshot(
double? fiveHourPercent = null,
DateTimeOffset? fiveHourResetsAt = null,
double? sevenDayPercent = null,
DateTimeOffset? sevenDayResetsAt = null,
int fiveHourThresholdPct = 80,
int sevenDayThresholdPct = 90,
bool isGateBlocked = false,
string? gateReason = null,
DateTime? fetchedAtUtc = null,
bool isStale = false,
string? lastError = null)
=> new(
fiveHourPercent, fiveHourResetsAt, sevenDayPercent, sevenDayResetsAt,
Array.Empty<UsageLimitDto>(), fiveHourThresholdPct, sevenDayThresholdPct,
isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError);
// ── Text formatting ─────────────────────────────────────────────────────
[Fact]
public void Text_BothBucketsKnown_ShowsBoth()
{
var worker = new FakeWorker { InitialSnapshot = Snapshot(fiveHourPercent: 30, sevenDayPercent: 57) };
var vm = new UsagePillViewModel(worker);
Assert.Contains("5h", vm.Text);
Assert.Contains("30", vm.Text);
Assert.Contains("7d", vm.Text);
Assert.Contains("57", vm.Text);
}
[Fact]
public void Text_OnlyFiveHourKnown_ShowsOnlyFiveHour()
{
var worker = new FakeWorker { InitialSnapshot = Snapshot(fiveHourPercent: 42) };
var vm = new UsagePillViewModel(worker);
Assert.Contains("5h", vm.Text);
Assert.Contains("42", vm.Text);
Assert.DoesNotContain("7d", vm.Text);
}
[Fact]
public void Text_NoSnapshot_ShowsEmptyPlaceholder()
{
var worker = new FakeWorker();
var vm = new UsagePillViewModel(worker);
Assert.Equal("Usage ", vm.Text);
}
// ── IsWarn / IsBlocked threshold logic ──────────────────────────────────
[Fact]
public void IsWarn_BelowThresholdMinusTen_IsFalse()
{
var worker = new FakeWorker { InitialSnapshot = Snapshot(fiveHourPercent: 69, fiveHourThresholdPct: 80) };
var vm = new UsagePillViewModel(worker);
Assert.False(vm.IsWarn);
Assert.False(vm.IsBlocked);
}
[Fact]
public void IsWarn_AtThresholdMinusTen_IsTrue()
{
var worker = new FakeWorker { InitialSnapshot = Snapshot(fiveHourPercent: 70, fiveHourThresholdPct: 80) };
var vm = new UsagePillViewModel(worker);
Assert.True(vm.IsWarn);
Assert.False(vm.IsBlocked);
}
[Fact]
public void IsBlocked_WhenGateReportsBlocked_IsTrue()
{
var worker = new FakeWorker
{
InitialSnapshot = Snapshot(fiveHourPercent: 80, fiveHourThresholdPct: 80, isGateBlocked: true, gateReason: "5h-Limit 80% >= 80%")
};
var vm = new UsagePillViewModel(worker);
Assert.True(vm.IsBlocked);
}
// ── Stale ────────────────────────────────────────────────────────────────
[Fact]
public void Stale_Snapshot_SetsIsStale_AndTooltipMentionsStamp()
{
var fetchedAt = new DateTime(2026, 8, 5, 9, 41, 0, DateTimeKind.Utc);
var worker = new FakeWorker { InitialSnapshot = Snapshot(isStale: true, fetchedAtUtc: fetchedAt, lastError: "timeout") };
var vm = new UsagePillViewModel(worker);
Assert.True(vm.IsStale);
Assert.Contains(fetchedAt.ToLocalTime().ToString("HH:mm"), vm.Tooltip);
Assert.Contains("timeout", vm.Tooltip);
}
// ── UsageUpdated event ───────────────────────────────────────────────────
[Fact]
public void UsageUpdated_Event_UpdatesText()
{
var worker = new FakeWorker();
var vm = new UsagePillViewModel(worker);
Assert.Equal("Usage ", vm.Text);
worker.RaiseUsageUpdated(Snapshot(fiveHourPercent: 12));
Assert.Contains("12", vm.Text);
}
}
@@ -107,6 +107,7 @@ sealed class FakeWorkerClient : IWorkerClient
public event Action<string>? PlanningMergeAbortedEvent; public event Action<string>? PlanningMergeAbortedEvent;
public event Action<string>? PlanningCompletedEvent; public event Action<string>? PlanningCompletedEvent;
public event Action<PrimeFiredEvent>? PrimeFired; public event Action<PrimeFiredEvent>? PrimeFired;
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
#pragma warning restore CS0067 #pragma warning restore CS0067
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null); public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
@@ -142,6 +143,12 @@ sealed class FakeWorkerClient : IWorkerClient
public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask; public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
public Task ClearOnlineInboxAuthAsync() => Task.CompletedTask; public Task ClearOnlineInboxAuthAsync() => Task.CompletedTask;
public IReadOnlyList<ActiveTask> GetActiveTasks() => System.Array.Empty<ActiveTask>(); public IReadOnlyList<ActiveTask> GetActiveTasks() => System.Array.Empty<ActiveTask>();
public Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult<UsageSnapshotDto?>(null);
public Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsageAsync(DateOnly from, DateOnly to)
=> Task.FromResult<IReadOnlyList<ModelUsageRowDto>>(System.Array.Empty<ModelUsageRowDto>());
public Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsageAsync(DateOnly from, DateOnly to)
=> Task.FromResult<IReadOnlyList<TaskUsageRowDto>>(System.Array.Empty<TaskUsageRowDto>());
} }
// ── Helper to build VM with pre-seeded Items ────────────────────────────────── // ── Helper to build VM with pre-seeded Items ──────────────────────────────────