Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality).

## [Unreleased]

## [0.8.6] - 2026-10-04

### Fixed

- Keep Dock percentages updating when an old Command Palette notification subscriber disconnects. Deliver display changes independently to each subscriber, remove permanently disconnected subscribers, and retain transient failures for the next refresh. ([PR #33](https://github.com/TheBeems/CodexUsageDock/pull/33))

### Changed

- Clarify forecast retention and the privacy-policy date, document Store updates when WinGet cannot determine a version, and correct the chart description. ([commit 533695b](https://github.com/TheBeems/CodexUsageDock/commit/533695bfee3661dc98efffd7f58844cf433ed09a))
Expand Down
73 changes: 73 additions & 0 deletions CodexUsageDock.Tests/DockNotificationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Xunit;

namespace CodexUsageDock.Tests;

[SuppressMessage("Performance", "CA1859:Use concrete types when possible",
Justification = "These tests exercise host subscriptions through the public WinRT interface.")]
public sealed class DockNotificationTests
{
[Theory]
[InlineData(unchecked((int)0x80010108))]
[InlineData(unchecked((int)0x800706BA))]
public void DisconnectedSubscriberDoesNotBlockCurrentHost(int hresult)
{
var item = new UsageDockListItem(new NoOpCommand());
IListItem hostItem = item;
var disconnectedCalls = 0;
var displayedTitle = string.Empty;
hostItem.PropChanged += (_, _) =>
{
disconnectedCalls++;
throw Marshal.GetExceptionForHR(hresult)!;
};
hostItem.PropChanged += (_, args) =>
{
if (args.PropertyName == nameof(ICommandItem.Title)) displayedTitle = hostItem.Title;
};

item.Title = "Week 93%";
Assert.Equal("Week 93%", displayedTitle);
item.Title = "Week 86%";
item.NotifyDisplayPropertiesChanged();

Assert.Equal("Week 86%", displayedTitle);
Assert.Equal(1, disconnectedCalls);
}

[Fact]
public void TransientSubscriberFailureAllowsOtherNotificationsAndRecovery()
{
var item = new UsageDockListItem(new NoOpCommand());
IListItem hostItem = item;
var fail = true;
var recoveredTitle = string.Empty;
var properties = new List<string>();
hostItem.PropChanged += (_, args) =>
{
if (fail) throw Marshal.GetExceptionForHR(unchecked((int)0x8001010D))!;
if (args.PropertyName == nameof(ICommandItem.Title)) recoveredTitle = hostItem.Title;
};
hostItem.PropChanged += (_, args) => properties.Add(args.PropertyName);

item.Title = "Week 86%";
item.NotifyDisplayPropertiesChanged();
Assert.Contains(nameof(ICommandItem.Title), properties);
Assert.Contains(nameof(ICommandItem.Subtitle), properties);
Assert.Contains(nameof(ICommandItem.Icon), properties);

fail = false;
item.NotifyDisplayPropertiesChanged();
Assert.Equal("Week 86%", recoveredTitle);
}

[Fact]
public void UsageItemExposesIsolatedNotificationsThroughWinRtInterface()
{
var map = typeof(UsageDockItem).GetInterfaceMap(typeof(INotifyPropChanged));
Assert.All(map.TargetMethods, method => Assert.Equal(typeof(UsageDockListItem), method.DeclaringType));
}
}
2 changes: 1 addition & 1 deletion CodexUsageDock.Tests/UsageDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
}

[Fact]
public void ClassifyWindows_RecognizesWeeklyWindowWhenItIsPrimary()

Check warning on line 78 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.ClassifyWindows_RecognizesWeeklyWindowWhenItIsPrimary() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var weekly = new RateLimitWindow(3, 10080, DateTimeOffset.Now.AddDays(7));

Expand All @@ -95,7 +95,7 @@
}

[Fact]
public void Summary_UsesWeeklyWindowWhenFiveHourWindowIsInactive()

Check warning on line 98 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.Summary_UsesWeeklyWindowWhenFiveHourWindowIsInactive() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var now = new DateTimeOffset(2026, 7, 12, 14, 0, 0, TimeSpan.Zero);
var snapshot = CodexUsageSnapshot.Loading with
Expand All @@ -112,7 +112,7 @@
}

[Fact]
public void MissingFiveHourWindow_CanBeDescribedAsInactive()

Check warning on line 115 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.MissingFiveHourWindow_CanBeDescribedAsInactive() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var text = CodexUsageDockPage.FormatWindow("5-hour window", null, DateTimeOffset.Now, "Currently inactive");

Expand All @@ -121,7 +121,7 @@
}

[Fact]
public void AppServerWorkingDirectory_IsNeverInheritedFromSystem32ForBareCommand()

Check warning on line 124 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.AppServerWorkingDirectory_IsNeverInheritedFromSystem32ForBareCommand() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var workingDirectory = CodexAppServerReader.GetSafeWorkingDirectory("codex.exe");

Expand Down Expand Up @@ -207,7 +207,7 @@
using var service = _environment.CreateService();
using var page = new CodexUsageDockPage(service, _environment.CreateSettings());

Assert.Equal("0.8.5", CodexUsageDockMetadata.Version);
Assert.Equal("0.8.6", CodexUsageDockMetadata.Version);
Assert.Equal($"Codex Usage - {CodexUsageDockMetadata.Version}", page.Title);
}

Expand Down Expand Up @@ -1220,7 +1220,7 @@
}

[Fact]
public void ParseResetCredits_UsesAvailableCountAndExpiryDetails()

Check warning on line 1223 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.ParseResetCredits_UsesAvailableCountAndExpiryDetails() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var json = JsonDocument.Parse("""
{
Expand All @@ -1243,7 +1243,7 @@
}

[Fact]
public void ParseResetCredits_PreservesCountWhenDetailsAreNull()

Check warning on line 1246 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.ParseResetCredits_PreservesCountWhenDetailsAreNull() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var json = JsonDocument.Parse("""{ "rateLimitResetCredits": { "availableCount": 3, "credits": null } }""");

Expand Down Expand Up @@ -1335,7 +1335,7 @@
}

[Fact]
public void ParseCredits_HandlesBalanceAndUnlimitedAccounts()

Check warning on line 1338 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.ParseCredits_HandlesBalanceAndUnlimitedAccounts() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var result = JsonDocument.Parse("{}");
using var balanceLimits = JsonDocument.Parse("""{ "credits": { "hasCredits": true, "unlimited": false, "balance": "10.00" } }""");
Expand Down Expand Up @@ -1377,7 +1377,7 @@
[InlineData(null, null, "-- resets")]
[InlineData(0, null, "0 resets")]
[InlineData(2, "10.00", "2 resets · 10.00")]
public void DockSummary_FormatsAvailableData(int? resetCount, string? balance, string expected)

Check warning on line 1380 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.DockSummary_FormatsAvailableData(int?, string?, string) (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var snapshot = new CodexUsageSnapshot(
null,
Expand Down Expand Up @@ -1438,7 +1438,7 @@
}

[Fact]
public void DetailFormatter_ReportsMissingExpiryRows()

Check warning on line 1441 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.DetailFormatter_ReportsMissingExpiryRows() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var resets = new RateLimitResetCredits(
2,
Expand All @@ -1464,7 +1464,7 @@
}

[Fact]
public void DashboardSummary_WarnsWhenPrimaryLimitIsLow()

Check warning on line 1467 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.DashboardSummary_WarnsWhenPrimaryLimitIsLow() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var now = new DateTimeOffset(2026, 7, 12, 14, 0, 0, TimeSpan.Zero);
var snapshot = new CodexUsageSnapshot(
Expand Down
2 changes: 1 addition & 1 deletion CodexUsageDock/CodexUsageDock.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<OutputType>WinExe</OutputType>
<RootNamespace>CodexUsageDock</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.8.5</Version>
<Version>0.8.6</Version>

<WindowsSdkPackageVersion>10.0.26100.68-preview</WindowsSdkPackageVersion>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
Expand Down
44 changes: 40 additions & 4 deletions CodexUsageDock/UsageDockListItem.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,52 @@
using System.Diagnostics;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;

namespace CodexUsageDock;

internal partial class UsageDockListItem(ICommand command) : ListItem(command)
internal partial class UsageDockListItem : ListItem, INotifyPropChanged
{
internal UsageDockListItem(ICommand command) : base(command)
{
// The pinned SDK stops multicast delivery after a failed COM subscriber.
// Bridge its notifications so an old host cannot block the current one.
base.PropChanged += ForwardPropertyChanged;
}

public new event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;

private void ForwardPropertyChanged(object sender, IPropChangedEventArgs args)
{
var handlers = PropChanged;
if (handlers is null) return;

foreach (TypedEventHandler<object, IPropChangedEventArgs> handler in handlers.GetInvocationList())
{
try
{
handler(this, args);
}
catch (Exception error)
{
// Only permanent RPC failures remove a subscriber. A busy host
// must still receive the next refresh after a transient error.
if (error.HResult is unchecked((int)0x80010108) or unchecked((int)0x800706BA))
{
PropChanged -= handler;
}

Trace.TraceWarning("Codex Usage Dock: display notification failed (HRESULT 0x{0:X8}).", error.HResult);
}
}
}

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));
ForwardPropertyChanged(this, new PropChangedEventArgs(nameof(Title)));
ForwardPropertyChanged(this, new PropChangedEventArgs(nameof(Subtitle)));
ForwardPropertyChanged(this, new PropChangedEventArgs(nameof(Icon)));
}
}
5 changes: 5 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ Always specify both `Platform` and its matching RID. Omitting `-r win-x64` or `-

Tests must use `TestEnvironment` or explicitly inject both history stores and a temporary settings path. Only the production parameterless service constructor selects the current user's storage. Never instantiate it in a unit test. Each test owns and deletes its unique temporary directory; synthetic quota events must never reach real user history. Token tests await `TokenRefreshTask` separately because `RefreshAsync` completes when limits are published. Forecast arithmetic fixtures should use continuous measurements; gap and stale-data scenarios are tested separately.

### Dock notifications

`DockNotificationTests` covers a remaining-allowance change from 93% to 86% with a failing host subscriber, permanent disconnection cleanup, transient recovery, and subscription through the WinRT interface. The pinned SDK stops multicast delivery after the first failing subscriber; `UsageDockListItem` bridges those notifications and isolates each recipient, matching the [upstream SDK fix](https://github.com/microsoft/PowerToys/blob/main/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/EventHelpers.cs). Preserve stable item and band identities and avoid provider catalog invalidation on routine refreshes. These tests verify notification delivery; use the integration matrix to verify the installed Dock rendering.

### Chart labels

The chart uses baked Segoe UI outlines because the Windows SVG image renderer does not support SVG text. `CodexUsageDock/ChartLabelGlyphs.cs` is generated source; regenerate it on Windows with `scripts/generate-chart-labels.ps1` and review the visual result. Runtime builds use the checked-in outlines and need no font loading or drawing dependency.
Expand Down Expand Up @@ -158,6 +162,7 @@ Complete every row on a clean x64 environment and a separate clean ARM64 environ
| Dock band | Required | Required | The band can be added, each enabled item opens details, and values update while Command Palette remains responsive. |
| Settings | Required | Required | Visibility, reset-time, refresh-interval, and adaptive-forecast choices apply immediately and persist after restarting Command Palette. Verify that disabling pauses learning without replaying measurements collected while paused, retains history, and that deleting learned history requires confirmation. |
| Live app-server | Required | Required | With a signed-in standalone Codex CLI, the details page identifies the CLI app-server as the source and refreshes live data. |
| Dock refresh | Required | Required | Pin the weekly Dock item and compare its percentage with the dashboard after manual and automatic refreshes that change the allowance. Repeat after **Reload Command Palette Extension** and leave the Dock running through several refreshes. The percentage keeps matching the dashboard without removing or repinning the band. |
| Local fallback | Required | Required | In an isolated test account with local session metadata but no launchable standalone CLI, fallback data appears and is identified as local session data. Do not rename or delete a real CLI installation to create this state. |
| No-data failure | Required | Required | In an isolated account with neither a CLI nor session data, the extension shows a bounded unavailable/error state and does not crash or loop. |

Expand Down