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.GetRequiredService<INotesApi>(),
sp.GetRequiredService<IMergeCoordinator>()));
sc.AddSingleton<UsagePillViewModel>(sp =>
new UsagePillViewModel(sp.GetRequiredService<IWorkerClient>()));
sc.AddSingleton<MissionControlViewModel>(sp =>
new MissionControlViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>()));
sp.GetRequiredService<IWorkerClient>(),
sp.GetRequiredService<UsagePillViewModel>()));
sc.AddSingleton<IslandsShellViewModel>(sp =>
{
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
+16
View File
@@ -562,5 +562,21 @@
"listSettings": { "untitled": "Unbenannt" },
"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" }
},
"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" },
"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" }
},
"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 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);
}
+57
View File
@@ -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();
}
+7
View File
@@ -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}">