From 4a39132fc8d1bce7f594ff69bbbce8a4612d32e9 Mon Sep 17 00:00:00 2001 From: Truong To Date: Wed, 18 Mar 2026 18:28:26 +0700 Subject: [PATCH 1/8] fix: redesign connection UX, API-only user support, immediate refresh on disconnect - Simplify subscription section to CLI auto-detect only (remove browser/manual session key) - Clearer labels: "Claude.ai Subscription" (% cap) vs "Claude Code API Usage" ($ cost) - Streamlined API setup: org selection auto-fetches users, single Confirm button - Skip subscription fetch for API-only users (only when HasClaudeAI) - Tray icon shows budget % for API-only users instead of empty 0% - Disconnect clears stale data and triggers immediate popover/widget refresh - InvalidateApiCache() bypasses 5-min throttle on disconnect/reconnect - Block scroll wheel on ComboBox to prevent accidental selection changes - Auto-scroll panels into view when org/user picker appears - Move user picker outside SetupPanel so Change User works when connected - Surface user fetch errors in UI instead of silent failure Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Interfaces/IUsageRefreshCoordinator.cs | 2 + .../Services/UsageRefreshCoordinator.cs | 9 +- src/ClaudeTracker/TrayIcon/TrayIconManager.cs | 33 ++++- .../ViewModels/ApiBillingViewModel.cs | 139 +++++++++++++----- .../ViewModels/PersonalUsageViewModel.cs | 8 +- .../ViewModels/PopoverViewModel.cs | 2 +- .../Views/FloatingUsageWindow.xaml.cs | 2 +- .../Views/Settings/ApiBillingView.xaml | 43 ++++-- .../Views/Settings/ApiBillingView.xaml.cs | 48 +++++- .../Views/Settings/PersonalUsageView.xaml | 132 ++++------------- .../Views/Settings/PersonalUsageView.xaml.cs | 110 ++++++-------- 11 files changed, 306 insertions(+), 222 deletions(-) diff --git a/src/ClaudeTracker/Services/Interfaces/IUsageRefreshCoordinator.cs b/src/ClaudeTracker/Services/Interfaces/IUsageRefreshCoordinator.cs index a44559e..17fa8d8 100644 --- a/src/ClaudeTracker/Services/Interfaces/IUsageRefreshCoordinator.cs +++ b/src/ClaudeTracker/Services/Interfaces/IUsageRefreshCoordinator.cs @@ -14,6 +14,8 @@ public interface IUsageRefreshCoordinator void Stop(); /// Triggers an immediate out-of-cycle refresh. void RefreshNow(); + /// Invalidates API fetch cache so next RefreshNow() re-fetches immediately. + void InvalidateApiCache(); /// Updates the refresh interval in seconds. void UpdateInterval(double seconds); /// Whether the refresh timer is currently running. diff --git a/src/ClaudeTracker/Services/UsageRefreshCoordinator.cs b/src/ClaudeTracker/Services/UsageRefreshCoordinator.cs index 704cf16..d3ccfa6 100644 --- a/src/ClaudeTracker/Services/UsageRefreshCoordinator.cs +++ b/src/ClaudeTracker/Services/UsageRefreshCoordinator.cs @@ -73,6 +73,11 @@ public void RefreshNow() _ = RefreshAsync(); } + public void InvalidateApiCache() + { + _lastApiFetch = DateTime.MinValue; + } + public void UpdateInterval(double seconds) { seconds = Math.Clamp(seconds, Constants.RefreshIntervals.MinSeconds, Constants.RefreshIntervals.MaxSeconds); @@ -107,8 +112,8 @@ private async Task RefreshAsync() try { - // Fetch Claude.ai usage - if (profile.HasClaudeAI || !string.IsNullOrEmpty(profile.CliCredentialsJSON)) + // Fetch Claude.ai subscription usage (only when explicitly configured) + if (profile.HasClaudeAI) { var usage = await _apiService.FetchUsageData(); _profileService.UpdateUsageData(profile.Id, claudeUsage: usage); diff --git a/src/ClaudeTracker/TrayIcon/TrayIconManager.cs b/src/ClaudeTracker/TrayIcon/TrayIconManager.cs index e9666bf..9deaa35 100644 --- a/src/ClaudeTracker/TrayIcon/TrayIconManager.cs +++ b/src/ClaudeTracker/TrayIcon/TrayIconManager.cs @@ -96,11 +96,32 @@ public void UpdateIcon() try { var profile = _profileService.ActiveProfile; - var usage = profile?.ClaudeUsage; + var claudeUsage = profile?.HasClaudeAI == true ? profile.ClaudeUsage : null; + var apiUsage = profile?.HasAPIConsole == true ? profile.ApiUsage : null; var iconConfig = profile?.IconConfig ?? MenuBarIconConfiguration.Default; var sessionConfig = iconConfig.GetConfig(MenuBarMetricType.Session); - var percentage = usage?.EffectiveSessionPercentage ?? 0; + double percentage; + string tooltipText; + + if (claudeUsage != null) + { + // Subscription user: show session usage % + percentage = claudeUsage.EffectiveSessionPercentage; + tooltipText = $"Claude Usage: {FormatterHelper.FormatPercentage(percentage)} used\nResets: {FormatterHelper.FormatTimeRemaining(claudeUsage.SessionResetTime)}"; + } + else if (apiUsage != null) + { + // API-only user: show budget usage % + percentage = apiUsage.UsagePercentage; + tooltipText = $"API Budget: {apiUsage.FormattedUsed} / {apiUsage.FormattedTotal} ({FormatterHelper.FormatPercentage(percentage)} used)"; + } + else + { + percentage = 0; + tooltipText = "Claude Tracker - No usage data"; + } + var displayPercentage = UsageStatusCalculator.GetDisplayPercentage( percentage, iconConfig.ShowRemainingPercentage); var status = UsageStatusCalculator.CalculateStatus( @@ -109,7 +130,9 @@ public void UpdateIcon() var isDark = App.IsSystemDarkMode(); var customColor = iconConfig.UseCustomColor ? iconConfig.CustomColorHex : null; - var metricPrefix = iconConfig.ShowIconNames ? "S:" : null; + var metricPrefix = iconConfig.ShowIconNames + ? (claudeUsage != null ? "S:" : "$:") + : null; var icon = _renderer.RenderIcon( displayPercentage, status, style, iconConfig.MonochromeMode, isDark, customColor, @@ -121,9 +144,7 @@ public void UpdateIcon() _trayIcon.Icon = icon; oldIcon?.Dispose(); - _tooltipText = usage != null - ? $"Claude Usage: {FormatterHelper.FormatPercentage(percentage)} used\nResets: {FormatterHelper.FormatTimeRemaining(usage.SessionResetTime)}" - : "Claude Tracker - No usage data"; + _tooltipText = tooltipText; }); } catch (Exception ex) diff --git a/src/ClaudeTracker/ViewModels/ApiBillingViewModel.cs b/src/ClaudeTracker/ViewModels/ApiBillingViewModel.cs index c5ab7cf..2935da4 100644 --- a/src/ClaudeTracker/ViewModels/ApiBillingViewModel.cs +++ b/src/ClaudeTracker/ViewModels/ApiBillingViewModel.cs @@ -11,6 +11,7 @@ public partial class ApiBillingViewModel : ObservableObject private readonly IClaudeApiService _apiService; private readonly IProfileService _profileService; private readonly ISettingsService _settingsService; + private readonly IUsageRefreshCoordinator _refreshCoordinator; [ObservableProperty] private string _apiKey = ""; [ObservableProperty] private bool _isTesting; @@ -20,19 +21,24 @@ public partial class ApiBillingViewModel : ObservableObject [ObservableProperty] private APIOrganization? _selectedOrg; [ObservableProperty] private bool _isConfigured; [ObservableProperty] private bool _showUserPicker; + [ObservableProperty] private bool _isLoadingUsers; [ObservableProperty] private ClaudeCodeUserMetrics? _selectedUser; + [ObservableProperty] private string _trackedUserLabel = ""; public ObservableCollection Organizations { get; } = new(); public ObservableCollection ClaudeCodeUsers { get; } = new(); - public ApiBillingViewModel(IClaudeApiService apiService, IProfileService profileService, ISettingsService settingsService) + public ApiBillingViewModel(IClaudeApiService apiService, IProfileService profileService, + ISettingsService settingsService, IUsageRefreshCoordinator refreshCoordinator) { _apiService = apiService; _profileService = profileService; _settingsService = settingsService; + _refreshCoordinator = refreshCoordinator; var profile = _profileService.ActiveProfile; IsConfigured = profile?.HasAPIConsole ?? false; + TrackedUserLabel = profile?.ApiUserSearch ?? ""; } [RelayCommand] @@ -42,6 +48,8 @@ private async Task TestConnection() IsTesting = true; TestStatus = "Testing connection..."; + ShowOrgPicker = false; + ShowUserPicker = false; try { @@ -54,12 +62,20 @@ private async Task TestConnection() if (orgs.Count == 1) { SelectedOrg = orgs[0]; - await SaveConfiguration(); + TestStatus = $"Connected to {orgs[0].DisplayName}"; + TestSuccess = true; + await FetchUsersForOrg(orgs[0]); } - else + else if (orgs.Count > 1) { ShowOrgPicker = true; - TestStatus = $"Found {orgs.Count} organizations."; + TestStatus = "Select your organization:"; + TestSuccess = true; + } + else + { + TestStatus = "No organizations found."; + TestSuccess = false; } } catch (Exception ex) @@ -73,33 +89,30 @@ private async Task TestConnection() } } + /// + /// Called when org is selected from the dropdown — immediately fetches users. + /// [RelayCommand] private async Task SelectOrganization() { if (SelectedOrg == null) return; - await SaveConfiguration(); + TestStatus = $"Connected to {SelectedOrg.DisplayName}"; + TestSuccess = true; + await FetchUsersForOrg(SelectedOrg); } - private async Task SaveConfiguration() + private async Task FetchUsersForOrg(APIOrganization org) { var profile = _profileService.ActiveProfile; - if (profile == null || SelectedOrg == null) return; - - var credentials = _profileService.LoadCredentials(profile.Id); - credentials.ApiSessionKey = ApiKey.Trim(); - credentials.ApiOrganizationId = SelectedOrg.Id; - _profileService.SaveCredentials(profile.Id, credentials); + if (profile == null) return; - TestStatus = $"Connected to {SelectedOrg.DisplayName}"; - TestSuccess = true; - IsConfigured = true; - ShowOrgPicker = false; + IsLoadingUsers = true; + ClaudeCodeUsers.Clear(); + ShowUserPicker = false; - // Auto-fetch Claude Code users for identity picker try { - var users = await _apiService.FetchClaudeCodeAllUsers(SelectedOrg.Id, ApiKey.Trim()); - ClaudeCodeUsers.Clear(); + var users = await _apiService.FetchClaudeCodeAllUsers(org.Id, ApiKey.Trim()); foreach (var user in users) ClaudeCodeUsers.Add(user); @@ -109,22 +122,70 @@ private async Task SaveConfiguration() if (!string.IsNullOrEmpty(profile.ApiUserSearch)) SelectedUser = users.FirstOrDefault(u => u.DisplayName == profile.ApiUserSearch); } + else + { + TestStatus = $"Connected to {org.DisplayName}, but no users found."; + } } catch (Exception ex) { + TestStatus = $"Connected, but failed to load users: {ex.Message}"; + TestSuccess = false; Services.LoggingService.Instance.LogWarning($"Failed to fetch Claude Code users: {ex.Message}"); } + finally + { + IsLoadingUsers = false; + } } + /// + /// Final confirm — saves org + user selection together. + /// [RelayCommand] - private async Task LoadClaudeCodeUsers() + private void SaveUserSelection() + { + if (SelectedUser == null || SelectedOrg == null) return; + var profile = _profileService.ActiveProfile; + if (profile == null) return; + + // Save credentials + var credentials = _profileService.LoadCredentials(profile.Id); + credentials.ApiSessionKey = ApiKey.Trim(); + credentials.ApiOrganizationId = SelectedOrg.Id; + _profileService.SaveCredentials(profile.Id, credentials); + + // Save user selection + profile.ApiUserSearch = SelectedUser.DisplayName; + _settingsService.Save(); + + ShowUserPicker = false; + ShowOrgPicker = false; + IsConfigured = true; + TrackedUserLabel = SelectedUser.DisplayName; + TestStatus = $"Tracking: {SelectedUser.DisplayName}"; + TestSuccess = true; + + _refreshCoordinator.InvalidateApiCache(); + _refreshCoordinator.RefreshNow(); + } + + [RelayCommand] + private async Task ChangeUser() { var profile = _profileService.ActiveProfile; if (profile == null) return; var credentials = _profileService.LoadCredentials(profile.Id); if (string.IsNullOrEmpty(credentials.ApiSessionKey) || string.IsNullOrEmpty(credentials.ApiOrganizationId)) + { + TestStatus = "No API credentials configured."; + TestSuccess = false; return; + } + + ApiKey = credentials.ApiSessionKey; + IsLoadingUsers = true; try { @@ -139,26 +200,22 @@ private async Task LoadClaudeCodeUsers() if (!string.IsNullOrEmpty(profile.ApiUserSearch)) SelectedUser = users.FirstOrDefault(u => u.DisplayName == profile.ApiUserSearch); } + else + { + TestStatus = "No users found in this organization."; + TestSuccess = false; + } } catch (Exception ex) { + TestStatus = $"Failed to load users: {ex.Message}"; + TestSuccess = false; Services.LoggingService.Instance.LogWarning($"Failed to fetch Claude Code users: {ex.Message}"); } - } - - [RelayCommand] - private void SaveUserSelection() - { - if (SelectedUser == null) return; - var profile = _profileService.ActiveProfile; - if (profile == null) return; - - profile.ApiUserSearch = SelectedUser.DisplayName; - _settingsService.Save(); - - ShowUserPicker = false; - TestStatus = $"Tracking: {SelectedUser.DisplayName}"; - TestSuccess = true; + finally + { + IsLoadingUsers = false; + } } [RelayCommand] @@ -172,8 +229,20 @@ private void Disconnect() credentials.ApiOrganizationId = null; _profileService.SaveCredentials(profile.Id, credentials); + profile.ApiUserSearch = null; + profile.ApiUsage = null; + profile.PersonalMetrics = null; + profile.DailyMetrics = null; + _settingsService.Save(); + ApiKey = ""; IsConfigured = false; + ShowUserPicker = false; + ShowOrgPicker = false; + TrackedUserLabel = ""; TestStatus = ""; + + _refreshCoordinator.InvalidateApiCache(); + _refreshCoordinator.RefreshNow(); } } diff --git a/src/ClaudeTracker/ViewModels/PersonalUsageViewModel.cs b/src/ClaudeTracker/ViewModels/PersonalUsageViewModel.cs index 74b6a3a..231a058 100644 --- a/src/ClaudeTracker/ViewModels/PersonalUsageViewModel.cs +++ b/src/ClaudeTracker/ViewModels/PersonalUsageViewModel.cs @@ -11,6 +11,7 @@ public partial class PersonalUsageViewModel : ObservableObject { private readonly IClaudeApiService _apiService; private readonly IProfileService _profileService; + private readonly IUsageRefreshCoordinator _refreshCoordinator; private readonly ClaudeCodeSyncService _cliSyncService; [ObservableProperty] private string _sessionKey = ""; @@ -30,14 +31,16 @@ public partial class PersonalUsageViewModel : ObservableObject public PersonalUsageViewModel( IClaudeApiService apiService, IProfileService profileService, + IUsageRefreshCoordinator refreshCoordinator, ClaudeCodeSyncService cliSyncService) { _apiService = apiService; _profileService = profileService; + _refreshCoordinator = refreshCoordinator; _cliSyncService = cliSyncService; var profile = _profileService.ActiveProfile; - IsConfigured = profile?.HasAnyCredentials ?? false; + IsConfigured = profile?.HasClaudeAI == true || !string.IsNullOrEmpty(profile?.CliCredentialsJSON); if (profile?.ClaudeSessionKey != null) SessionKey = profile.ClaudeSessionKey; @@ -178,6 +181,7 @@ private void Disconnect() profile.CliCredentialsJSON = null; profile.HasCliAccount = false; profile.CliAccountSyncedAt = null; + profile.ClaudeUsage = null; _profileService.UpdateProfile(profile); SessionKey = ""; @@ -188,6 +192,8 @@ private void Disconnect() AutoDetectSuccess = false; ConnectedLabel = ""; ConnectedDetail = ""; + + _refreshCoordinator.RefreshNow(); } private void UpdateConnectedInfo() diff --git a/src/ClaudeTracker/ViewModels/PopoverViewModel.cs b/src/ClaudeTracker/ViewModels/PopoverViewModel.cs index 5a1c8c3..1ac091c 100644 --- a/src/ClaudeTracker/ViewModels/PopoverViewModel.cs +++ b/src/ClaudeTracker/ViewModels/PopoverViewModel.cs @@ -161,7 +161,7 @@ public void RefreshData() ProfileName = profile.Name; HasCredentials = profile.HasUsageCredentials; - var usage = profile.ClaudeUsage; + var usage = profile.HasClaudeAI ? profile.ClaudeUsage : null; HasClaudeUsage = usage != null; if (usage != null) { diff --git a/src/ClaudeTracker/Views/FloatingUsageWindow.xaml.cs b/src/ClaudeTracker/Views/FloatingUsageWindow.xaml.cs index e0104df..873c711 100644 --- a/src/ClaudeTracker/Views/FloatingUsageWindow.xaml.cs +++ b/src/ClaudeTracker/Views/FloatingUsageWindow.xaml.cs @@ -57,7 +57,7 @@ public FloatingUsageWindow() var profile = App.Services.GetRequiredService().ActiveProfile; if (profile == null) return; - var usage = profile.ClaudeUsage; + var usage = profile.HasClaudeAI ? profile.ClaudeUsage : null; if (usage != null) LastUpdatedText.Text = $"Updated {Utilities.FormatterHelper.FormatTimeAgo(usage.LastUpdated)}"; else if (profile.ApiUsage != null) diff --git a/src/ClaudeTracker/Views/Settings/ApiBillingView.xaml b/src/ClaudeTracker/Views/Settings/ApiBillingView.xaml index 4ecac32..b34a176 100644 --- a/src/ClaudeTracker/Views/Settings/ApiBillingView.xaml +++ b/src/ClaudeTracker/Views/Settings/ApiBillingView.xaml @@ -4,26 +4,36 @@ xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"> - - + - + -