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:
@@ -139,4 +139,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input);
|
||||
Task SetOnlineInboxAuthAsync(string refreshToken);
|
||||
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, bool, string?>? RefineFinishedEvent;
|
||||
|
||||
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
||||
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
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)));
|
||||
_hub.On<string, bool, string?>("RefineFinished", (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()
|
||||
@@ -574,6 +579,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task ClearOnlineInboxAuthAsync()
|
||||
=> 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)
|
||||
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||
=> await StartPlanningSessionAsync(taskId, ct);
|
||||
@@ -676,3 +690,46 @@ public sealed record OnlineInboxConfigInputDto(
|
||||
string ClientId,
|
||||
string Scopes,
|
||||
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 IWorkerClient? Worker { get; }
|
||||
public MissionControlViewModel? MissionControl { get; }
|
||||
public UsagePillViewModel? UsagePill { get; }
|
||||
public UpdateCheckService UpdateCheck => _updateCheck;
|
||||
|
||||
public string ConnectionText =>
|
||||
@@ -208,10 +209,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
Func<WeeklyReportModalViewModel> weeklyReportVmFactory,
|
||||
Func<MergeModalViewModel> mergeVmFactory,
|
||||
Func<RepoImportModalViewModel> repoImportVmFactory,
|
||||
MissionControlViewModel missionControl)
|
||||
MissionControlViewModel missionControl,
|
||||
UsagePillViewModel usagePill)
|
||||
{
|
||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||
MissionControl = missionControl;
|
||||
UsagePill = usagePill;
|
||||
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
|
||||
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
|
||||
MissionControl.ErrorReported += FlashFooterError;
|
||||
|
||||
@@ -54,10 +54,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
|
||||
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;
|
||||
_worker = worker;
|
||||
UsagePill = usagePill;
|
||||
|
||||
ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
|
||||
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:vm="using:ClaudeDo.Ui.ViewModels"
|
||||
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
|
||||
xmlns:controls="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:Class="ClaudeDo.Ui.Views.MainWindow"
|
||||
@@ -184,6 +185,12 @@
|
||||
</StackPanel>
|
||||
</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 -->
|
||||
<Button DockPanel.Dock="Right"
|
||||
Command="{Binding OpenLogVisualizerCommand}"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
|
||||
xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl"
|
||||
xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl"
|
||||
xmlns:controls="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:DataType="vm:MissionControlViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView">
|
||||
@@ -25,6 +26,9 @@
|
||||
LetterSpacing="1.4" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<controls:UsagePill DataContext="{Binding UsagePill}"
|
||||
Margin="0,0,4,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Classes="icon-btn"
|
||||
Click="OnNewSessionClicked"
|
||||
ToolTip.Tip="{loc:Tr missionControl.newSession}">
|
||||
|
||||
Reference in New Issue
Block a user