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:
@@ -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);
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 ──────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user