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:
@@ -36,6 +36,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public event Action<string>? PlanningMergeAbortedEvent;
|
||||
public event Action<string>? PlanningCompletedEvent;
|
||||
public event Action<PrimeFiredEvent>? PrimeFired;
|
||||
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
||||
#pragma warning restore CS0067
|
||||
|
||||
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 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));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
private sealed class FakeWorker : StubWorkerClient { }
|
||||
|
||||
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 ──────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user