From 3c50c02edaf2a272fa88dd78fec77c9b4a784aa8 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:51:27 +0200 Subject: [PATCH 1/2] fix: preserve Dock bands and respect the active display mode --- CodexUsageDock.Tests/ProviderDockTests.cs | 438 +++++++++++++++++- CodexUsageDock.Tests/UsageDataTests.cs | 12 +- .../CodexUsageDockCommandsProvider.cs | 83 ++-- .../Pages/CodexUsageDockSettingsPage.cs | 2 +- CodexUsageDock/UsageDockBand.cs | 48 ++ CodexUsageDock/UsageDockItem.cs | 2 +- CodexUsageDock/UsageDockListItem.cs | 16 + README.md | 2 +- 8 files changed, 556 insertions(+), 47 deletions(-) create mode 100644 CodexUsageDock/UsageDockBand.cs create mode 100644 CodexUsageDock/UsageDockListItem.cs diff --git a/CodexUsageDock.Tests/ProviderDockTests.cs b/CodexUsageDock.Tests/ProviderDockTests.cs index 6fdea69..f25e50b 100644 --- a/CodexUsageDock.Tests/ProviderDockTests.cs +++ b/CodexUsageDock.Tests/ProviderDockTests.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Text.Json; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Xunit; @@ -50,11 +52,441 @@ public void SeparateDockBandsHaveStableRestorableIdentities() foreach (var band in bands) { Assert.Single(Assert.IsAssignableFrom(band.Command).GetItems()); - Assert.Equal(band.Command.Id, provider.GetCommandItem(band.Command.Id)!.Command.Id); + Assert.Same(band, provider.GetCommandItem(band.Command.Id)); } Assert.Null(provider.GetCommandItem("unknown")); Assert.Null(provider.GetCommandItem(string.Empty)); - var combined = provider.GetCommandItem("nl.mathijs.codexusage.dock"); - Assert.Equal(3, Assert.IsAssignableFrom(combined!.Command).GetItems().Length); + Assert.Null(provider.GetCommandItem(CombinedDockId)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DockModeTransitionsKeepStableBandObjectsAndPersist(bool initiallySeparate) + { + File.WriteAllText( + _environment.PathFor("settings.json"), + JsonSerializer.Serialize(new Dictionary + { + [SeparateDockItemsKey] = initiallySeparate.ToString().ToLowerInvariant(), + })); + + var initialId = initiallySeparate ? FiveHourDockId : CombinedDockId; + var settings = _environment.CreateSettings(); + using (var service = _environment.CreateService()) + using (var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { })) + { + var initialBand = FindBand(provider, initialId); + Assert.Same(initialBand, provider.GetCommandItem(initialId)); + + SubmitSettings(settings, + (SeparateDockItemsKey, (!initiallySeparate).ToString().ToLowerInvariant())); + Assert.Equal(initiallySeparate ? 1 : 3, provider.GetDockBands()!.Length); + + SubmitSettings(settings, + (SeparateDockItemsKey, initiallySeparate.ToString().ToLowerInvariant())); + Assert.Same(initialBand, FindBand(provider, initialId)); + Assert.Same(initialBand, provider.GetCommandItem(initialId)); + } + + using var restartedService = _environment.CreateService(); + using var restartedProvider = new CodexUsageDockCommandsProvider( + restartedService, + _environment.CreateSettings(), + _ => { }); + Assert.Equal(initiallySeparate ? 3 : 1, restartedProvider.GetDockBands()!.Length); + Assert.Same( + FindBand(restartedProvider, initialId), + restartedProvider.GetCommandItem(initialId)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DockModeSwitchPersistsTheOppositeModeAcrossRestart(bool initiallySeparate) + { + File.WriteAllText( + _environment.PathFor("settings.json"), + JsonSerializer.Serialize(new Dictionary + { + [SeparateDockItemsKey] = initiallySeparate.ToString().ToLowerInvariant(), + })); + + var settings = _environment.CreateSettings(); + var oldIds = initiallySeparate + ? new[] { FiveHourDockId, WeeklyDockId, CreditsDockId } + : new[] { CombinedDockId }; + var newIds = initiallySeparate + ? new[] { CombinedDockId } + : new[] { FiveHourDockId, WeeklyDockId, CreditsDockId }; + using (var service = _environment.CreateService()) + using (var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { })) + { + SubmitSettings(settings, (SeparateDockItemsKey, (!initiallySeparate).ToString().ToLowerInvariant())); + foreach (var id in oldIds) + { + Assert.Null(provider.GetCommandItem(id)); + } + } + + using var restartedService = _environment.CreateService(); + using var restartedProvider = new CodexUsageDockCommandsProvider( + restartedService, + _environment.CreateSettings(), + _ => { }); + Assert.Equal(newIds, restartedProvider.GetDockBands()!.Select(band => band.Command.Id)); + foreach (var id in oldIds) + { + Assert.Null(restartedProvider.GetCommandItem(id)); + } + foreach (var id in newIds) + { + Assert.Same(FindBand(restartedProvider, id), restartedProvider.GetCommandItem(id)); + } + } + + [Fact] + public void RestorableLookupUsesOnlyTheActiveDockSelection() + { + File.WriteAllText(_environment.PathFor("settings.json"), $"{{\"{SeparateDockItemsKey}\":\"true\"}}"); + using var service = _environment.CreateService(); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }); + + foreach (var band in provider.GetDockBands()!) + { + Assert.Same(band, provider.GetCommandItem(band.Command.Id)); + } + + Assert.Null(provider.GetCommandItem(CombinedDockId)); + SubmitSettings(settings, (SeparateDockItemsKey, "false")); + + var combined = FindBand(provider, CombinedDockId); + Assert.Same(combined, provider.GetCommandItem(CombinedDockId)); + Assert.Null(provider.GetCommandItem(FiveHourDockId)); + Assert.Null(provider.GetCommandItem(WeeklyDockId)); + Assert.Null(provider.GetCommandItem(CreditsDockId)); + } + + [Fact] + public void HiddenAndDisabledDockIdsAreNotRestorableInBothModes() + { + using var service = _environment.CreateService(); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }); + + Assert.NotNull(provider.GetCommandItem(CombinedDockId)); + Assert.Null(provider.GetCommandItem(FiveHourDockId)); + Assert.Null(provider.GetCommandItem(ClaudeDockId)); + + SubmitSettings(settings, (ShowFiveHourLimitKey, "false")); + Assert.Null(provider.GetCommandItem(FiveHourDockId)); + Assert.Equal(2, Assert.IsAssignableFrom(FindBand(provider, CombinedDockId).Command).GetItems().Length); + + SubmitSettings(settings, + (ShowWeeklyLimitKey, "false"), + (ShowResetsAndCreditsKey, "false")); + Assert.Empty(provider.GetDockBands()!); + foreach (var id in AllDockIds) + { + Assert.Null(provider.GetCommandItem(id)); + } + + SubmitSettings(settings, (SeparateDockItemsKey, "true"), (ShowWeeklyLimitKey, "true")); + Assert.Single(provider.GetDockBands()!); + Assert.Null(provider.GetCommandItem(CombinedDockId)); + Assert.Null(provider.GetCommandItem(FiveHourDockId)); + Assert.Same( + FindBand(provider, WeeklyDockId), + provider.GetCommandItem(WeeklyDockId)); + Assert.Null(provider.GetCommandItem(CreditsDockId)); + Assert.Null(provider.GetCommandItem(ClaudeDockId)); + Assert.Null(provider.GetCommandItem("testhost-owned-pin")); + + SubmitSettings(settings, (ShowWeeklyLimitKey, "false")); + Assert.Empty(provider.GetDockBands()!); + Assert.Null(provider.GetCommandItem(WeeklyDockId)); + } + + [Fact] + public void RetainedBandPagesAreClearedWhenTheirBandBecomesInactive() + { + using var service = _environment.CreateService(); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }); + + var combinedBand = FindBand(provider, CombinedDockId); + var combinedPage = Assert.IsAssignableFrom(combinedBand.Command); + Assert.Equal(3, combinedPage.GetItems().Length); + var combinedEmptyNotifications = 0; + combinedPage.ItemsChanged += (_, _) => + { + if (combinedPage.GetItems().Length == 0) + { + combinedEmptyNotifications++; + } + }; + + SubmitSettings(settings, + (ShowFiveHourLimitKey, "false"), + (ShowWeeklyLimitKey, "false"), + (ShowResetsAndCreditsKey, "false")); + Assert.Empty(combinedPage.GetItems()); + Assert.True(combinedEmptyNotifications > 0); + Assert.Empty(provider.GetDockBands()!); + + SubmitSettings(settings, (ShowWeeklyLimitKey, "true")); + Assert.Same(combinedBand, FindBand(provider, CombinedDockId)); + Assert.Single(combinedPage.GetItems()); + + var emptyNotificationsBeforeSeparate = combinedEmptyNotifications; + SubmitSettings(settings, (SeparateDockItemsKey, "true")); + Assert.Empty(combinedPage.GetItems()); + Assert.True(combinedEmptyNotifications > emptyNotificationsBeforeSeparate); + var weeklyBand = FindBand(provider, WeeklyDockId); + var weeklyPage = Assert.IsAssignableFrom(weeklyBand.Command); + Assert.Single(weeklyPage.GetItems()); + var weeklyEmptyNotifications = 0; + weeklyPage.ItemsChanged += (_, _) => + { + if (weeklyPage.GetItems().Length == 0) + { + weeklyEmptyNotifications++; + } + }; + + SubmitSettings(settings, (ShowWeeklyLimitKey, "false")); + Assert.Empty(weeklyPage.GetItems()); + Assert.True(weeklyEmptyNotifications > 0); + Assert.Empty(provider.GetDockBands()!); + } + + [Fact] + public void RetainedClaudeBandPageClearsAndNotifiesWhenClaudeIsDisabled() + { + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var capture = WriteClaudeCapture(now, 25); + File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( + new Dictionary + { + [EnableClaudeKey] = "true", + [ClaudeBridgePathKey] = capture, + })); + using var service = _environment.CreateService( + _ => Task.FromResult(CodexUsageSnapshot.Loading), + () => CodexUsageSnapshot.Loading, + clock: () => now); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }, () => now); + + var band = FindBand(provider, ClaudeDockId); + var page = Assert.IsAssignableFrom(band.Command); + Assert.Equal(2, page.GetItems().Length); + var emptyNotifications = 0; + page.ItemsChanged += (_, _) => + { + if (page.GetItems().Length == 0) + { + emptyNotifications++; + } + }; + + SubmitSettings(settings, (EnableClaudeKey, "false")); + + Assert.Empty(page.GetItems()); + Assert.True(emptyNotifications > 0); + Assert.Null(provider.GetCommandItem(ClaudeDockId)); + } + + [Fact] + public async Task QuotaRefreshKeepsBandIdentityAndNotifiesOnlyItsBandList() + { + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var snapshot = CodexUsageSnapshot.Loading with + { + Primary = new RateLimitWindow(25, 300, now.AddHours(4)), + Secondary = new RateLimitWindow(40, 10080, now.AddDays(5)), + UpdatedAt = now, + Source = UsageDataSource.AppServer, + Error = null, + AccountKey = "test-account", + DefaultBucketId = "codex", + }; + using var service = _environment.CreateService( + _ => pending.Task, + () => CodexUsageSnapshot.Loading, + clock: () => now); + using var provider = new CodexUsageDockCommandsProvider( + service, + _environment.CreateSettings(), + _ => { }, + () => now); + var band = FindBand(provider, CombinedDockId); + var list = Assert.IsAssignableFrom(band.Command); + var providerInvalidations = 0; + var bandInvalidations = 0; + provider.ItemsChanged += (_, _) => providerInvalidations++; + list.ItemsChanged += (_, _) => bandInvalidations++; + + var refresh = service.RefreshAsync(); + pending.SetResult(snapshot); + await refresh.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Same(band, FindBand(provider, CombinedDockId)); + Assert.Same(band, provider.GetCommandItem(CombinedDockId)); + Assert.Equal(0, providerInvalidations); + Assert.True(bandInvalidations > 0); + } + + [Fact] + public async Task RepeatedCompletedQuotaRefreshNotifiesAPreviouslyReadItem() + { + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var firstRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var readNumber = 0; + var snapshot = CodexUsageSnapshot.Loading with + { + Primary = new RateLimitWindow(25, 300, now.AddHours(4)), + Secondary = new RateLimitWindow(40, 10080, now.AddDays(5)), + UpdatedAt = now, + Source = UsageDataSource.AppServer, + Error = null, + AccountKey = "test-account", + DefaultBucketId = "codex", + ResetCredits = new RateLimitResetCredits(2, null), + }; + Task Read(CancellationToken _) => + Interlocked.Increment(ref readNumber) == 1 ? firstRead.Task : secondRead.Task; + using var service = _environment.CreateService( + Read, + () => CodexUsageSnapshot.Loading, + clock: () => now); + using var provider = new CodexUsageDockCommandsProvider( + service, + _environment.CreateSettings(), + _ => { }, + () => now); + + var band = FindBand(provider, CombinedDockId); + // Reset-credit text is independent of the real-time window validity clock. + var item = Assert.IsAssignableFrom(band.Command).GetItems()[2]; + var cachedTitle = item.Title; + var firstRefresh = service.RefreshAsync(); + firstRead.SetResult(snapshot); + await firstRefresh.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotEqual(item.Title, cachedTitle); + item.PropChanged += (_, args) => + { + if (args.PropertyName == nameof(ICommandItem.Title)) cachedTitle = item.Title; + }; + + var secondRefresh = service.RefreshAsync(); + secondRead.SetResult(snapshot); + await secondRefresh.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(item.Title, cachedTitle); + } + + [Fact] + public async Task ClaudeRefreshKeepsBandIdentityAndNotifiesOnlyItsBandList() + { + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var firstCapture = WriteClaudeCapture(now, 25); + var secondCapture = WriteClaudeCapture(now, 35); + File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( + new Dictionary + { + [EnableClaudeKey] = "true", + [ClaudeBridgePathKey] = firstCapture, + })); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var service = _environment.CreateService( + _ => pending.Task, + () => CodexUsageSnapshot.Loading, + clock: () => now); + using var provider = new CodexUsageDockCommandsProvider( + service, + _environment.CreateSettings(), + _ => { }, + () => now); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + + var codexBand = FindBand(provider, CombinedDockId); + var claudeBand = FindBand(provider, ClaudeDockId); + var claudeList = Assert.IsAssignableFrom(claudeBand.Command); + var providerInvalidations = 0; + var claudeInvalidations = 0; + provider.ItemsChanged += (_, _) => providerInvalidations++; + claudeList.ItemsChanged += (_, _) => claudeInvalidations++; + + service.ConfigureClaude(false, firstCapture); + service.ConfigureClaude(true, secondCapture); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Same(codexBand, FindBand(provider, CombinedDockId)); + Assert.Same(claudeBand, FindBand(provider, ClaudeDockId)); + Assert.Same(claudeBand, provider.GetCommandItem(ClaudeDockId)); + Assert.Equal(0, providerInvalidations); + Assert.True(claudeInvalidations > 0); + + pending.TrySetResult(CodexUsageSnapshot.Loading); + } + + private const string CombinedDockId = "nl.mathijs.codexusage.dock"; + private const string FiveHourDockId = "nl.mathijs.codexusage.dock.five-hour"; + private const string WeeklyDockId = "nl.mathijs.codexusage.dock.weekly"; + private const string CreditsDockId = "nl.mathijs.codexusage.dock.credits"; + private const string ClaudeDockId = "nl.mathijs.codexusage.dock.claude"; + private const string SeparateDockItemsKey = "separateDockItems"; + private const string ShowFiveHourLimitKey = "showFiveHourLimit"; + private const string ShowWeeklyLimitKey = "showWeeklyLimit"; + private const string ShowResetsAndCreditsKey = "showResetsAndCredits"; + private const string EnableClaudeKey = "enableClaude"; + private const string ClaudeBridgePathKey = "claudeBridgePath"; + private static readonly string[] AllDockIds = [ + CombinedDockId, + FiveHourDockId, + WeeklyDockId, + CreditsDockId, + ClaudeDockId, + ]; + + private static ICommandItem FindBand(CodexUsageDockCommandsProvider provider, string id) => + Assert.Single(provider.GetDockBands() ?? Array.Empty(), item => item.Command.Id == id); + + private static void SubmitSettings( + CodexUsageDockSettingsPage page, + params (string Key, string Value)[] values) + { + var payload = values.ToDictionary(pair => pair.Key, pair => pair.Value); + page.GetContent().OfType().Last().SubmitForm(JsonSerializer.Serialize(payload), "{}"); + } + + private string WriteClaudeCapture(DateTimeOffset now, int fiveHourUsed) + { + var path = _environment.PathFor($"claude-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, JsonSerializer.Serialize(new + { + schemaVersion = 1, + provider = "claude", + observedAtUTC = now.ToString("O", CultureInfo.InvariantCulture), + rate_limits = new + { + five_hour = new + { + used_percentage = fiveHourUsed, + resets_at = now.AddHours(4).ToUnixTimeSeconds(), + }, + seven_day = new + { + used_percentage = 40, + resets_at = now.AddDays(4).ToUnixTimeSeconds(), + }, + }, + })); + return path; } } diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 66598a2..6f9bb60 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -445,7 +445,7 @@ public async Task DetailsPageRefreshUpdatesMainContentAndDetailsPane() } [Fact] - public async Task CompletedRefreshRebuildsAndInvalidatesDockBands() + public async Task CompletedRefreshUpdatesExistingDockBandWithoutReloadingProvider() { var now = DateTimeOffset.Now; var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -457,6 +457,10 @@ public async Task CompletedRefreshRebuildsAndInvalidatesDockBands() { var invalidationCount = 0; provider.ItemsChanged += (_, _) => invalidationCount++; + var band = Assert.Single(provider.GetDockBands()!); + var list = Assert.IsAssignableFrom(band.Command); + var bandInvalidations = 0; + list.ItemsChanged += (_, _) => bandInvalidations++; var refresh = service.RefreshAsync(); result.SetResult(CodexUsageSnapshot.Loading with @@ -468,9 +472,9 @@ public async Task CompletedRefreshRebuildsAndInvalidatesDockBands() }); await refresh.WaitAsync(AsyncTestTimeout); - Assert.Equal(1, invalidationCount); - var band = Assert.Single(provider.GetDockBands()!); - var list = Assert.IsAssignableFrom(band.Command); + Assert.Equal(0, invalidationCount); + Assert.True(bandInvalidations > 0); + Assert.Same(band, Assert.Single(provider.GetDockBands()!)); Assert.Contains(list.GetItems(), item => item.Title == "5h 75%"); } finally diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index 2688702..651c30e 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -20,8 +20,8 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private readonly CodexUsageTablePage _textUsage; private readonly CodexProfilesPage _profiles; private readonly ClaudeUsagePage _claude; - private readonly ListItem _claudeFiveHour; - private readonly ListItem _claudeWeekly; + private readonly UsageDockListItem _claudeFiveHour; + private readonly UsageDockListItem _claudeWeekly; private readonly object _claudePresentationLock = new(); private readonly UsageAlertEvaluator _alerts = new(); private readonly Action _notify; @@ -31,6 +31,13 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private const string WeeklyDockId = "nl.mathijs.codexusage.dock.weekly"; private const string CreditsDockId = "nl.mathijs.codexusage.dock.credits"; private const string ClaudeDockId = "nl.mathijs.codexusage.dock.claude"; + private readonly object _dockLayoutLock = new(); + private readonly UsageDockBand _combinedBand; + private readonly UsageDockBand _fiveHourBand; + private readonly UsageDockBand _weeklyBand; + private readonly UsageDockBand _creditsBand; + private readonly UsageDockBand _claudeBand; + private readonly UsageDockBand[] _allDockBands; private ICommandItem[] _dockBands = []; public CodexUsageDockCommandsProvider() @@ -68,12 +75,18 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _profiles = new CodexProfilesPage(new CodexProfileStore(_settings.ProfileStoragePath)); _profiles.ProfileSelected += OnProfileSelected; _claude = new ClaudeUsagePage(_usage); - _claudeFiveHour = new ListItem(_claude); - _claudeWeekly = new ListItem(_claude); + _claudeFiveHour = new UsageDockListItem(_claude); + _claudeWeekly = new UsageDockListItem(_claude); _details.Commands = [.. _details.Commands, new CommandContextItem(_textUsage) { Title = "Read usage in text" }]; _fiveHour = new UsageDockItem(_usage, UsageDockItemKind.FiveHour, details, _settings); _weekly = new UsageDockItem(_usage, UsageDockItemKind.Weekly, details, _settings); _resetsAndCredits = new UsageDockItem(_usage, UsageDockItemKind.ResetsAndCredits, details); + _combinedBand = new("nl.mathijs.codexusage.dock", DisplayName); + _fiveHourBand = new(FiveHourDockId, "Codex five-hour usage"); + _weeklyBand = new(WeeklyDockId, "Codex weekly usage"); + _creditsBand = new(CreditsDockId, "Codex resets and credits"); + _claudeBand = new(ClaudeDockId, "Claude usage"); + _allDockBands = [_combinedBand, _fiveHourBand, _weeklyBand, _creditsBand, _claudeBand]; _commands = [ @@ -111,29 +124,19 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _usage.Updated += OnUsageUpdated; _usage.ClaudeUpdated += OnClaudeUpdated; RefreshClaudeItems(); - RebuildDockBands(); + UpdateDockLayout(); _usage.Start(); } public override ICommandItem[] TopLevelCommands() => _commands; - public override ICommandItem[]? GetDockBands() => _dockBands; + public override ICommandItem[]? GetDockBands() => [.. Volatile.Read(ref _dockBands)]; public override ICommandItem? GetCommandItem(string id) { if (string.IsNullOrWhiteSpace(id)) return null; - var known = _commands.Concat(_dockBands).FirstOrDefault(item => item.Command.Id == id); - if (known is not null) return known; - return id switch - { - "nl.mathijs.codexusage.dock" => new WrappedDockItem(GetVisibleDockItems(), "nl.mathijs.codexusage.dock", DisplayName), - FiveHourDockId => new WrappedDockItem([_fiveHour], FiveHourDockId, "Codex five-hour usage"), - WeeklyDockId => new WrappedDockItem([_weekly], WeeklyDockId, "Codex weekly usage"), - CreditsDockId => new WrappedDockItem([_resetsAndCredits], CreditsDockId, "Codex resets and credits"), - ClaudeDockId => new WrappedDockItem(_settings.EnableClaude ? [_claudeFiveHour, _claudeWeekly] : [], ClaudeDockId, "Claude usage"), - _ => null, - }; + return _commands.Concat(Volatile.Read(ref _dockBands)).FirstOrDefault(item => item.Command.Id == id); } private void OnSettingsChanged(object? sender, EventArgs e) @@ -154,8 +157,7 @@ private void OnSettingsChanged(object? sender, EventArgs e) _details.Refresh(); _planner.Refresh(); _history.Refresh(); - RebuildDockBands(); - RaiseItemsChanged(); + UpdateDockLayout(); if (sourceChanged) _ = _usage.RefreshAsync(); } @@ -171,8 +173,7 @@ private bool ApplySourceSettings() private void OnClaudeUpdated(object? sender, EventArgs args) { RefreshClaudeItems(); - RebuildDockBands(); - RaiseItemsChanged(); + if (_claudeBand.HasItems) _claudeBand.NotifyItemsChanged(); } private void RefreshClaudeItems() @@ -212,8 +213,10 @@ private void OnUsageUpdated(object? sender, EventArgs e) return; } - RebuildDockBands(); - RaiseItemsChanged(); + foreach (var band in Volatile.Read(ref _dockBands).OfType()) + { + if (!ReferenceEquals(band, _claudeBand)) band.NotifyItemsChanged(); + } var alerts = _alerts.Evaluate(_usage.GetPresentation(), _clock(), _usage.RefreshInterval, new UsageAlertOptions(Enabled: _settings.EnableUsageAlerts)); if (alerts.Count > 0) @@ -224,25 +227,31 @@ private void OnUsageUpdated(object? sender, EventArgs e) } } - private void RebuildDockBands() + private void UpdateDockLayout() { - var items = GetVisibleDockItems(); - ICommandItem[] bands; - if (_settings.SeparateDockItems) + var changedBands = new List(); + bool catalogChanged; + lock (_dockLayoutLock) { - bands = items.Select(item => new WrappedDockItem([item], - ReferenceEquals(item, _fiveHour) ? FiveHourDockId : ReferenceEquals(item, _weekly) ? WeeklyDockId : CreditsDockId, - ReferenceEquals(item, _fiveHour) ? "Codex five-hour usage" : ReferenceEquals(item, _weekly) ? "Codex weekly usage" : "Codex resets and credits")) - .Cast().ToArray(); + var separate = _settings.SeparateDockItems; + Publish(_combinedBand, separate ? [] : GetVisibleDockItems()); + Publish(_fiveHourBand, separate && _settings.ShowFiveHourLimit ? [_fiveHour] : []); + Publish(_weeklyBand, separate && _settings.ShowWeeklyLimit ? [_weekly] : []); + Publish(_creditsBand, separate && _settings.ShowResetsAndCredits ? [_resetsAndCredits] : []); + Publish(_claudeBand, _settings.EnableClaude ? [_claudeFiveHour, _claudeWeekly] : []); + ICommandItem[] bands = _allDockBands.Where(band => band.HasItems).ToArray(); + catalogChanged = !Volatile.Read(ref _dockBands).SequenceEqual(bands); + Volatile.Write(ref _dockBands, bands); } - else + + // No host callback may run while the layout lock is held. + foreach (var band in changedBands) band.NotifyItemsChanged(); + if (catalogChanged) RaiseItemsChanged(); + + void Publish(UsageDockBand band, IListItem[] items) { - var dockBand = items.Length == 0 ? null : new WrappedDockItem(items, "nl.mathijs.codexusage.dock", DisplayName); - bands = dockBand is null ? [] : [dockBand]; + if (band.PublishItems(items)) changedBands.Add(band); } - if (_settings.EnableClaude) - bands = [.. bands, new WrappedDockItem([_claudeFiveHour, _claudeWeekly], ClaudeDockId, "Claude usage")]; - _dockBands = bands; } private IListItem[] GetVisibleDockItems() diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 6ef4aae..5c84922 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -87,7 +87,7 @@ internal CodexUsageDockSettingsPage(string path) _settings.Add(new ToggleSetting(SeparateDockItemsKey, false) { Label = "Separate Dock items", - Description = "Show each usage item as its own Dock entry.", + Description = "Offer separate metric bands instead of the combined band. Other-mode pins are hidden. After switching, add the desired bands through Dock customization if needed.", }); _settings.Add(new ToggleSetting(ShowAccountActivityKey, true) { diff --git a/CodexUsageDock/UsageDockBand.cs b/CodexUsageDock/UsageDockBand.cs new file mode 100644 index 0000000..867d57d --- /dev/null +++ b/CodexUsageDock/UsageDockBand.cs @@ -0,0 +1,48 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal sealed partial class UsageDockBand : CommandItem +{ + private readonly DockBandPage _page; + + internal UsageDockBand(string id, string title) : this(new DockBandPage(id, title)) { } + + private UsageDockBand(DockBandPage page) : base(page) => _page = page; + + internal bool HasItems => _page.HasItems; + internal bool PublishItems(IListItem[] items) => _page.PublishItems(items); + internal void NotifyItemsChanged() => _page.NotifyItemsChanged(); + + private sealed partial class DockBandPage : ListPage + { + private IListItem[] _items = []; + + internal DockBandPage(string id, string title) + { + Id = id; + Name = title; + Title = title; + } + + internal bool HasItems => Volatile.Read(ref _items).Length > 0; + public override IListItem[] GetItems() => [.. Volatile.Read(ref _items)]; + + // The host synchronously calls GetItems from ItemsChanged. Publish the + // complete layout before notifying, including newly inactive bands. + internal bool PublishItems(IListItem[] items) + { + if (Volatile.Read(ref _items).SequenceEqual(items)) return false; + Volatile.Write(ref _items, items); + return true; + } + + internal void NotifyItemsChanged() + { + var items = Volatile.Read(ref _items); + foreach (var item in items.OfType()) item.NotifyDisplayPropertiesChanged(); + RaiseItemsChanged(Volatile.Read(ref _items).Length); + } + } +} diff --git a/CodexUsageDock/UsageDockItem.cs b/CodexUsageDock/UsageDockItem.cs index 9a03271..46c3576 100644 --- a/CodexUsageDock/UsageDockItem.cs +++ b/CodexUsageDock/UsageDockItem.cs @@ -10,7 +10,7 @@ internal enum UsageDockItemKind ResetsAndCredits, } -internal sealed partial class UsageDockItem : ListItem, IDisposable +internal sealed partial class UsageDockItem : UsageDockListItem, IDisposable { private readonly CodexUsageService _usage; private readonly UsageDockItemKind _kind; diff --git a/CodexUsageDock/UsageDockListItem.cs b/CodexUsageDock/UsageDockListItem.cs new file mode 100644 index 0000000..9771c52 --- /dev/null +++ b/CodexUsageDock/UsageDockListItem.cs @@ -0,0 +1,16 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal partial class UsageDockListItem(ICommand command) : ListItem(command) +{ + internal void NotifyDisplayPropertiesChanged() + { + // A host can subscribe after reading the initial values and miss an + // intervening update. Refresh even unchanged values on the next read. + OnPropertyChanged(nameof(Title)); + OnPropertyChanged(nameof(Subtitle)); + OnPropertyChanged(nameof(Icon)); + } +} diff --git a/README.md b/README.md index d9c95d1..e276ed3 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The Dock will show entries similar to `5h 47%`, `Week 86%`, and `2 resets ยท 10. ## Customize the Dock -**Compact Dock** shortens quota labels to forms such as `5h47%` and `W86%` and hides reset times while retaining stale/source warnings. **Separate Dock items** offers each metric as a separate pinnable band; existing combined-band and individual pin identifiers remain resolvable after changing modes. +**Compact Dock** shortens quota labels to forms such as `5h47%` and `W86%` and hides reset times while retaining stale/source warnings. **Separate Dock items** offers each visible metric as a separate pinnable band. Turning it off offers the combined band. Pins belonging to the inactive mode, hidden metrics, and the disabled Claude pilot stop displaying items and are not restored as active bands after a reload. Command Palette keeps its saved pins: switching modes does not move or convert them. Add the desired bands through Dock customization if they were not already pinned; switching back makes matching saved pins available again. **Enable usage alerts** is off by default. When enabled, fresh, identified account data can notify on a downward crossing of 10% remaining, a new projected limit within one hour, or a reset credit entering its last 24 hours. The first measurement establishes a baseline. Duplicate refreshes do not repeat alerts, small reset-time fluctuations stay in the same cycle, and account/category changes start a new baseline. Multiple simultaneous alerts are combined into one host notification. Delivery depends on the Command Palette host. From 36363df7e8051d279d6a1288fbb980ae95aa0b07 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:52:24 +0200 Subject: [PATCH 2/2] docs: record Dock mode fix and regression coverage --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b771e3..51e76d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Each entry links to the commit or pull request that introduced the change. ### Fixed +- Switching Dock modes or hiding a metric no longer restores inactive saved bands. Existing band objects are retained, and usage refreshes update their items without reloading the whole provider. ([PR #22](https://github.com/TheBeems/CodexUsageDock/pull/22)) - Keep provider updates independent and serialize source-sensitive presentation changes so delayed updates cannot restore old account or Claude values. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18))