import { useState, useEffect, useMemo } from 'react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { useSetupStore } from '@/store/setup-store'; import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon'; import { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } from '@/hooks/queries'; // Error codes for distinguishing failure modes const ERROR_CODES = { API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE', AUTH_ERROR: 'AUTH_ERROR', NOT_AVAILABLE: 'NOT_AVAILABLE', TRUST_PROMPT: 'TRUST_PROMPT', UNKNOWN: 'UNKNOWN', } as const; type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; type UsageError = { code: ErrorCode; message: string; }; const CLAUDE_SESSION_WINDOW_HOURS = 5; // Helper to format reset time for Codex/z.ai (unix timestamp in seconds or milliseconds) function formatResetTime(unixTimestamp: number, isMilliseconds = false): string { const date = new Date(isMilliseconds ? unixTimestamp : unixTimestamp * 1000); const now = new Date(); const diff = date.getTime() - now.getTime(); if (diff < 3600000) { const mins = Math.ceil(diff / 60000); return `Resets in ${mins}m`; } if (diff < 86400000) { const hours = Math.floor(diff / 3600000); const mins = Math.ceil((diff % 3600000) / 60000); return `Resets in ${hours}h ${mins > 0 ? `${mins}m` : ''}`; } return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; } // Legacy alias for Codex function formatCodexResetTime(unixTimestamp: number): string { return formatResetTime(unixTimestamp, false); } // Helper to format window duration for Codex function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } { if (durationMins < 60) { return { title: `${durationMins}min Window`, subtitle: 'Rate limit' }; } if (durationMins < 1440) { const hours = Math.round(durationMins / 60); return { title: `${hours}h Window`, subtitle: 'Rate limit' }; } const days = Math.round(durationMins / 1440); return { title: `${days}d Window`, subtitle: 'Rate limit' }; } // Helper to format large numbers with K/M suffixes function formatNumber(num: number): string { if (num >= 1_000_000_000) { return `${(num / 1_000_000_000).toFixed(1)}B`; } if (num >= 1_000_000) { return `${(num / 1_000_000).toFixed(1)}M`; } if (num >= 1_000) { return `${(num / 1_000).toFixed(1)}K`; } return num.toLocaleString(); } export function UsagePopover() { const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); const geminiAuthStatus = useSetupStore((state) => state.geminiAuthStatus); const [open, setOpen] = useState(false); const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai' | 'gemini'>('claude'); // Check authentication status const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated; const isCodexAuthenticated = codexAuthStatus?.authenticated; const isZaiAuthenticated = zaiAuthStatus?.authenticated; const isGeminiAuthenticated = geminiAuthStatus?.authenticated; // Use React Query hooks for usage data // Only enable polling when popover is open AND the tab is active const { data: claudeUsage, isLoading: claudeLoading, error: claudeQueryError, dataUpdatedAt: claudeUsageLastUpdated, refetch: refetchClaude, } = useClaudeUsage(open && activeTab === 'claude' && isClaudeAuthenticated); const { data: codexUsage, isLoading: codexLoading, error: codexQueryError, dataUpdatedAt: codexUsageLastUpdated, refetch: refetchCodex, } = useCodexUsage(open && activeTab === 'codex' && isCodexAuthenticated); const { data: zaiUsage, isLoading: zaiLoading, error: zaiQueryError, dataUpdatedAt: zaiUsageLastUpdated, refetch: refetchZai, } = useZaiUsage(open && activeTab === 'zai' && isZaiAuthenticated); const { data: geminiUsage, isLoading: geminiLoading, error: geminiQueryError, dataUpdatedAt: geminiUsageLastUpdated, refetch: refetchGemini, } = useGeminiUsage(open && activeTab === 'gemini' && isGeminiAuthenticated); // Parse errors into structured format const claudeError = useMemo((): UsageError | null => { if (!claudeQueryError) return null; const message = claudeQueryError instanceof Error ? claudeQueryError.message : String(claudeQueryError); // Detect trust prompt error const isTrustPrompt = message.includes('Trust prompt') || message.includes('folder permission'); if (isTrustPrompt) { return { code: ERROR_CODES.TRUST_PROMPT, message }; } if (message.includes('API bridge')) { return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; } return { code: ERROR_CODES.AUTH_ERROR, message }; }, [claudeQueryError]); const codexError = useMemo((): UsageError | null => { if (!codexQueryError) return null; const message = codexQueryError instanceof Error ? codexQueryError.message : String(codexQueryError); if (message.includes('not available') || message.includes('does not provide')) { return { code: ERROR_CODES.NOT_AVAILABLE, message }; } if (message.includes('API bridge')) { return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; } return { code: ERROR_CODES.AUTH_ERROR, message }; }, [codexQueryError]); const zaiError = useMemo((): UsageError | null => { if (!zaiQueryError) return null; const message = zaiQueryError instanceof Error ? zaiQueryError.message : String(zaiQueryError); if (message.includes('not configured') || message.includes('API token')) { return { code: ERROR_CODES.NOT_AVAILABLE, message }; } if (message.includes('API bridge')) { return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; } return { code: ERROR_CODES.AUTH_ERROR, message }; }, [zaiQueryError]); const geminiError = useMemo((): UsageError | null => { if (!geminiQueryError) return null; const message = geminiQueryError instanceof Error ? geminiQueryError.message : String(geminiQueryError); if (message.includes('not configured') || message.includes('not authenticated')) { return { code: ERROR_CODES.NOT_AVAILABLE, message }; } if (message.includes('API bridge')) { return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; } return { code: ERROR_CODES.AUTH_ERROR, message }; }, [geminiQueryError]); // Determine which tab to show by default useEffect(() => { if (isClaudeAuthenticated) { setActiveTab('claude'); } else if (isCodexAuthenticated) { setActiveTab('codex'); } else if (isZaiAuthenticated) { setActiveTab('zai'); } else if (isGeminiAuthenticated) { setActiveTab('gemini'); } }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated, isGeminiAuthenticated]); // Check if data is stale (older than 2 minutes) const isClaudeStale = useMemo(() => { return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; }, [claudeUsageLastUpdated]); const isCodexStale = useMemo(() => { return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; }, [codexUsageLastUpdated]); const isZaiStale = useMemo(() => { return !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; }, [zaiUsageLastUpdated]); const isGeminiStale = useMemo(() => { return !geminiUsageLastUpdated || Date.now() - geminiUsageLastUpdated > 2 * 60 * 1000; }, [geminiUsageLastUpdated]); // Refetch functions for manual refresh const fetchClaudeUsage = () => refetchClaude(); const fetchCodexUsage = () => refetchCodex(); const fetchZaiUsage = () => refetchZai(); const fetchGeminiUsage = () => refetchGemini(); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { if (percentage >= 75) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' }; if (percentage >= 50) return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' }; return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' }; }; // Helper component for the progress bar const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => (
{subtitle}
{claudeError.message}
{claudeError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
'Ensure the Electron bridge is running or restart the app'
) : claudeError.code === ERROR_CODES.TRUST_PROMPT ? (
<>
Run claude in
your terminal and approve access to continue
>
) : (
<>
Make sure Claude CLI is installed and authenticated via{' '}
claude login
>
)}
Loading usage data...
{codexError.code === ERROR_CODES.NOT_AVAILABLE ? 'Usage not available' : codexError.message}
{codexError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
'Ensure the Electron bridge is running or restart the app'
) : codexError.code === ERROR_CODES.NOT_AVAILABLE ? (
<>
Codex CLI doesn't provide usage statistics. Check{' '}
OpenAI dashboard
{' '}
for usage details.
>
) : (
<>
Make sure Codex CLI is installed and authenticated via{' '}
codex login
>
)}
Loading usage data...
Plan:{' '} {codexUsage.rateLimits.planType.charAt(0).toUpperCase() + codexUsage.rateLimits.planType.slice(1)}
No usage data available
{zaiError.code === ERROR_CODES.NOT_AVAILABLE ? 'z.ai not configured' : zaiError.message}
{zaiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
'Ensure the Electron bridge is running or restart the app'
) : zaiError.code === ERROR_CODES.NOT_AVAILABLE ? (
<>
Set Z_AI_API_KEY{' '}
environment variable to enable z.ai usage tracking
>
) : (
<>Check your z.ai API key configuration>
)}
Loading usage data...
Plan:{' '} {zaiUsage.quotaLimits.planType.charAt(0).toUpperCase() + zaiUsage.quotaLimits.planType.slice(1)}
No usage data available
{geminiError.code === ERROR_CODES.NOT_AVAILABLE ? 'Gemini not configured' : geminiError.message}
{geminiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
'Ensure the Electron bridge is running or restart the app'
) : geminiError.code === ERROR_CODES.NOT_AVAILABLE ? (
<>
Run{' '}
gemini auth login{' '}
to authenticate with your Google account
>
) : (
<>Check your Gemini CLI configuration>
)}
Loading usage data...
Connected
Authenticated via{' '} {geminiUsage.authMethod === 'cli_login' ? 'CLI Login' : geminiUsage.authMethod === 'api_key_env' ? 'API Key (Environment)' : geminiUsage.authMethod === 'api_key' ? 'API Key' : 'Unknown'}
{geminiUsage.error ? ( <>Quota API: {geminiUsage.error}> ) : ( <>No usage yet or quota data unavailable> )}
Not authenticated
Run gemini auth login{' '}
to authenticate