Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
144 changes: 144 additions & 0 deletions apps/frontend/src/renderer/components/UsageIndicator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* @vitest-environment jsdom
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { UsageIndicator } from './UsageIndicator';
import { useSettingsStore } from '../stores/settings-store';

vi.mock('../stores/settings-store', () => ({
useSettingsStore: vi.fn()
}));

vi.mock('./ui/tooltip', () => ({
TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>
}));

vi.mock('./ui/popover', () => ({
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <>{children}</>
}));

vi.mock('react-i18next', () => ({
useTranslation: vi.fn(() => ({
t: (key: string) => {
const translations: Record<string, string> = {
'common:usage.loading': 'Loading usage',
'common:usage.reauthRequired': 'Re-authentication required',
'common:usage.reauthRequiredDescription': 'Your session has expired. Re-authenticate to continue.',
'common:usage.clickToOpenSettings': 'Open settings',
'common:usage.dataUnavailable': 'Usage data unavailable',
'common:usage.dataUnavailableDescription': 'Usage data is not currently available.',
'common:usage.notAvailable': 'N/A'
};
return translations[key] || key;
},
i18n: { language: 'en' }
}))
}));

let mockActiveProfileId: string | null = null;
let onAllProfilesUsageUpdatedCallback: ((allProfilesUsage: AllProfilesUsagePayload) => void) | undefined;
type AllProfilesUsagePayload = ReturnType<typeof buildAllProfilesUsageResponse>['data'];

function buildAllProfilesUsageResponse(needsReauthentication: boolean) {
return {
success: true,
data: {
activeProfile: {
sessionPercent: 0,
weeklyPercent: 0,
profileId: 'oauth-profile-1',
profileName: 'OAuth Profile 1',
fetchedAt: new Date(),
needsReauthentication
},
allProfiles: [
{
profileId: 'oauth-profile-1',
profileName: 'OAuth Profile 1',
sessionPercent: 0,
weeklyPercent: 0,
isAuthenticated: true,
isRateLimited: false,
availabilityScore: 100,
isActive: true,
needsReauthentication
}
],
fetchedAt: new Date()
}
};
}

describe('UsageIndicator re-auth handling by auth mode', () => {
beforeEach(() => {
vi.clearAllMocks();
mockActiveProfileId = null;
onAllProfilesUsageUpdatedCallback = undefined;

vi.mocked(useSettingsStore).mockImplementation((selector) => {
const state = { activeProfileId: mockActiveProfileId } satisfies { activeProfileId: string | null };
return selector(state as any);
});
Comment on lines +88 to +92
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

as any cast masks store type mismatches — consider typing the minimal state slice.

If the activeProfileId field is renamed or its type changes in the store, TypeScript won't catch the mismatch here.

♻️ Proposed fix
-  vi.mocked(useSettingsStore).mockImplementation((selector) => {
-    const state = { activeProfileId: mockActiveProfileId };
-    return selector(state as any);
-  });
+  vi.mocked(useSettingsStore).mockImplementation((selector) => {
+    const state: Pick<Parameters<typeof useSettingsStore>[0] extends (s: infer S) => unknown ? S : never, 'activeProfileId'> = {
+      activeProfileId: mockActiveProfileId
+    };
+    return selector(state as any);
+  });

Or, more simply:

-    const state = { activeProfileId: mockActiveProfileId };
-    return selector(state as any);
+    return selector({ activeProfileId: mockActiveProfileId } as Parameters<typeof useSettingsStore>[0] extends (s: infer S) => unknown ? S : never);

The simplest acceptable middle ground is keeping as any but documenting the fields the mock exposes:

-  vi.mocked(useSettingsStore).mockImplementation((selector) => {
-    const state = { activeProfileId: mockActiveProfileId };
-    return selector(state as any);
+  vi.mocked(useSettingsStore).mockImplementation((selector) => {
+    // Minimal store slice required by UsageIndicator
+    const state = { activeProfileId: mockActiveProfileId } satisfies { activeProfileId: string | null };
+    return selector(state as any);
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
vi.mocked(useSettingsStore).mockImplementation((selector) => {
const state = { activeProfileId: mockActiveProfileId };
return selector(state as any);
});
vi.mocked(useSettingsStore).mockImplementation((selector) => {
// Minimal store slice required by UsageIndicator
const state = { activeProfileId: mockActiveProfileId } satisfies { activeProfileId: string | null };
return selector(state as any);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/frontend/src/renderer/components/UsageIndicator.test.tsx` around lines
83 - 86, Replace the unsafe cast in the test mock by typing the minimal store
slice instead of using "as any": update the
vi.mocked(useSettingsStore).mockImplementation to accept the selector and pass a
properly typed object containing only activeProfileId (use the store's selector
type or create a small interface like { activeProfileId: typeof
mockActiveProfileId }) so the selector invocation in useSettingsStore receives a
correctly typed state; this removes the as any cast while keeping the mock
focused on the minimal shape required for the test.


(window as any).electronAPI = {
onUsageUpdated: vi.fn(() => vi.fn()),
onAllProfilesUsageUpdated: vi.fn((callback: (allProfilesUsage: AllProfilesUsagePayload) => void) => {
onAllProfilesUsageUpdatedCallback = callback;
return vi.fn();
}),
requestUsageUpdate: vi.fn().mockResolvedValue({ success: false, data: null }),
requestAllProfilesUsage: vi.fn().mockResolvedValue(buildAllProfilesUsageResponse(true))
};
});

it('does not show OAuth re-auth UI in API profile mode', async () => {
mockActiveProfileId = 'api-profile-1';

render(<UsageIndicator />);

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Usage data unavailable' })).toBeInTheDocument();
});

expect(screen.queryByText('Re-authentication required')).not.toBeInTheDocument();
});

it('shows re-auth UI in OAuth mode when active profile needs re-authentication', async () => {
render(<UsageIndicator />);

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Re-authentication required' })).toBeInTheDocument();
});

expect(screen.getByText('Re-authentication required')).toBeInTheDocument();
});

it('ignores re-auth updates from usage events in API profile mode', async () => {
mockActiveProfileId = 'api-profile-1';

render(<UsageIndicator />);

await waitFor(() => {
expect(onAllProfilesUsageUpdatedCallback).toBeDefined();
});

await act(async () => {
onAllProfilesUsageUpdatedCallback?.(buildAllProfilesUsageResponse(true).data);
});

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Usage data unavailable' })).toBeInTheDocument();
});

expect(screen.queryByText('Re-authentication required')).not.toBeInTheDocument();
});
});
Comment on lines +82 to +144
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding the symmetrical OAuth-mode event-driven test.

Test 3 validates that API profile mode ignores a re-auth update from onAllProfilesUsageUpdated. The complementary case — that OAuth mode does react to the same event and flips to show re-auth UI — is not covered. Without it, a regression in the event-handler branch for OAuth mode (e.g. the callback being no-op'd accidentally) could pass the existing suite undetected.

♻️ Sketch of the missing test
it('shows re-auth UI after onAllProfilesUsageUpdated fires in OAuth mode', async () => {
  // Start with no re-auth needed so the initial fetch doesn't trigger re-auth UI.
  (window as any).electronAPI.requestAllProfilesUsage = vi
    .fn()
    .mockResolvedValue(buildAllProfilesUsageResponse(false));

  render(<UsageIndicator />);

  // Confirm initial state has no re-auth UI.
  await waitFor(() => {
    expect(screen.queryByText('Re-authentication required')).not.toBeInTheDocument();
  });

  // Simulate a real-time update where the active profile now needs re-auth.
  await act(async () => {
    onAllProfilesUsageUpdatedCallback?.(buildAllProfilesUsageResponse(true).data);
  });

  await waitFor(() => {
    expect(
      screen.getByRole('button', { name: 'Re-authentication required' })
    ).toBeInTheDocument();
  });
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/frontend/src/renderer/components/UsageIndicator.test.tsx` around lines
82 - 146, Add a new test in the same describe block that verifies OAuth mode
reacts to onAllProfilesUsageUpdated: mock
window.electronAPI.requestAllProfilesUsage to return
buildAllProfilesUsageResponse(false) so initial render of UsageIndicator shows
no "Re-authentication required", render <UsageIndicator />, wait for absence of
re-auth UI, then invoke onAllProfilesUsageUpdatedCallback with
buildAllProfilesUsageResponse(true).data and assert the "Re-authentication
required" button/text appears; reference the existing
onAllProfilesUsageUpdatedCallback, requestAllProfilesUsage, UsageIndicator, and
buildAllProfilesUsageResponse helpers to implement this.

39 changes: 22 additions & 17 deletions apps/frontend/src/renderer/components/UsageIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { useTranslation } from 'react-i18next';
import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time';
import type { ClaudeUsageSnapshot, ProfileUsageSummary } from '../../shared/types/agent';
import { useSettingsStore } from '@/stores/settings-store';
import type { AppSection } from './settings/AppSettings';

/**
Expand Down Expand Up @@ -82,6 +83,8 @@ export function UsageIndicator() {
const [isOpen, setIsOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const activeApiProfileId = useSettingsStore((state) => state.activeProfileId);
const isUsingApiProfile = Boolean(activeApiProfileId);

/**
* Helper function to get initials from a profile name
Expand Down Expand Up @@ -309,6 +312,10 @@ export function UsageIndicator() {
(hasHardcodedText(usage?.weeklyResetTime) ? undefined : usage?.weeklyResetTime))
: (hasHardcodedText(usage?.weeklyResetTime) ? undefined : usage?.weeklyResetTime);

// Re-authentication is only relevant for OAuth mode, never for API profiles
const showReauthForActiveAccount = !isUsingApiProfile && Boolean(usage?.needsReauthentication);
const showUnavailableReauth = !isUsingApiProfile && activeProfileNeedsReauth;

useEffect(() => {
// Listen for usage updates from main process
const unsubscribe = window.electronAPI.onUsageUpdated((snapshot: ClaudeUsageSnapshot) => {
Expand All @@ -322,9 +329,9 @@ export function UsageIndicator() {
// Filter out the active profile - we only want to show "other" profiles
const nonActiveProfiles = allProfilesUsage.allProfiles.filter(p => !p.isActive);
setOtherProfiles(nonActiveProfiles);
// Track if active profile needs re-auth
// Track if active profile needs re-auth (OAuth mode only)
const activeProfile = allProfilesUsage.allProfiles.find(p => p.isActive);
setActiveProfileNeedsReauth(activeProfile?.needsReauthentication ?? false);
setActiveProfileNeedsReauth(!isUsingApiProfile && Boolean(activeProfile?.needsReauthentication));
});

// Request initial usage on mount
Expand All @@ -347,11 +354,9 @@ export function UsageIndicator() {
if (result.success && result.data) {
const nonActiveProfiles = result.data.allProfiles.filter(p => !p.isActive);
setOtherProfiles(nonActiveProfiles);
// Track if active profile needs re-auth (even if main usage is unavailable)
// Track if active profile needs re-auth (OAuth mode only)
const activeProfile = result.data.allProfiles.find(p => p.isActive);
if (activeProfile?.needsReauthentication) {
setActiveProfileNeedsReauth(true);
}
setActiveProfileNeedsReauth(!isUsingApiProfile && Boolean(activeProfile?.needsReauthentication));
}
}).catch((error) => {
console.warn('[UsageIndicator] Failed to fetch all profiles usage:', error);
Expand All @@ -361,7 +366,7 @@ export function UsageIndicator() {
unsubscribe();
unsubscribeAllProfiles?.();
};
}, []);
}, [isUsingApiProfile]);

// Show loading state
if (isLoading) {
Expand All @@ -376,7 +381,7 @@ export function UsageIndicator() {
// Show unavailable state - with better messaging based on cause
if (!isAvailable || !usage) {
// Check if it's a re-auth issue (better UX than generic "not supported")
const needsReauth = activeProfileNeedsReauth;
const needsReauth = showUnavailableReauth;

return (
<TooltipProvider delayDuration={200}>
Expand Down Expand Up @@ -440,7 +445,7 @@ export function UsageIndicator() {

// Badge color based on the limiting (higher) percentage
// Override to red/destructive when re-auth is needed
const badgeColorClasses = usage.needsReauthentication
const badgeColorClasses = showReauthForActiveAccount
? 'text-red-500 bg-red-500/10 border-red-500/20'
: getBadgeColorClasses(limitingPercent);

Expand All @@ -461,7 +466,7 @@ export function UsageIndicator() {

const maxUsage = Math.max(usage.sessionPercent, usage.weeklyPercent);
// Show AlertCircle when re-auth needed or high usage
const Icon = usage.needsReauthentication ? AlertCircle :
const Icon = showReauthForActiveAccount ? AlertCircle :
maxUsage >= THRESHOLD_WARNING ? AlertCircle :
maxUsage >= THRESHOLD_ELEVATED ? TrendingUp :
Activity;
Expand All @@ -478,7 +483,7 @@ export function UsageIndicator() {
>
<Icon className="h-3.5 w-3.5 flex-shrink-0" />
{/* Show "!" when re-auth needed, otherwise dual usage display */}
{usage.needsReauthentication ? (
{showReauthForActiveAccount ? (
<span className="text-xs font-semibold text-red-500" title={t('common:usage.needsReauth')}>
!
</span>
Expand Down Expand Up @@ -510,7 +515,7 @@ export function UsageIndicator() {
</div>

{/* Re-auth required prompt - shown when active profile needs re-authentication */}
{usage.needsReauthentication ? (
{showReauthForActiveAccount ? (
<div className="py-2 space-y-3">
<div className="flex items-start gap-2.5 p-2.5 rounded-lg bg-destructive/10 border border-destructive/20">
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
Expand Down Expand Up @@ -615,16 +620,16 @@ export function UsageIndicator() {
{/* Initials Avatar with warning indicator for re-auth needed */}
<div className="relative">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
usage.needsReauthentication ? 'bg-red-500/10' : 'bg-primary/10'
showReauthForActiveAccount ? 'bg-red-500/10' : 'bg-primary/10'
}`}>
<span className={`text-xs font-semibold ${
usage.needsReauthentication ? 'text-red-500' : 'text-primary'
showReauthForActiveAccount ? 'text-red-500' : 'text-primary'
}`}>
{getInitials(usage.profileName)}
</span>
</div>
{/* Status dot for re-auth needed */}
{usage.needsReauthentication && (
{showReauthForActiveAccount && (
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-background" />
)}
</div>
Expand All @@ -635,14 +640,14 @@ export function UsageIndicator() {
<span className="text-[10px] text-muted-foreground font-medium">
{t('common:usage.activeAccount')}
</span>
{usage.needsReauthentication && (
{showReauthForActiveAccount && (
<span className="text-[9px] px-1.5 py-0.5 bg-red-500/10 text-destructive rounded font-semibold">
{t('common:usage.needsReauth')}
</span>
)}
</div>
<div className={`font-medium text-xs truncate ${
usage.needsReauthentication ? 'text-destructive' : 'text-primary'
showReauthForActiveAccount ? 'text-destructive' : 'text-primary'
}`}>
{usage.profileEmail || usage.profileName}
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default defineConfig({
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@': resolve(__dirname, 'src/renderer'),
'@main': resolve(__dirname, 'src/main'),
'@renderer': resolve(__dirname, 'src/renderer'),
'@shared': resolve(__dirname, 'src/shared')
Comment on lines +27 to 30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

@ and @renderer now resolve to the same path.

Both aliases point to src/renderer. This is intentional (per commit message) and not a problem, but worth noting for future maintainers that @renderer is now fully redundant with @.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/frontend/vitest.config.ts` around lines 27 - 30, The alias mapping
defines both '@' and '@renderer' to resolve(__dirname, 'src/renderer'), making
'@renderer' redundant; either remove the duplicate '@renderer' entry from the
alias object or keep it but add an inline comment explaining the
redundancy/intent so future maintainers understand that '@' and '@renderer'
intentionally resolve to the same path (update the aliases object where '@' and
'@renderer' are defined).

Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading