From d1b579ad1b8c58da352f1a2c803256a427e3f669 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 24 Jan 2026 12:22:49 -0500 Subject: [PATCH 01/21] fix(api): add race condition prevention and input validation for account control - add mutex pattern with providerLocks Map for solo mode - convert soloAccount to async function - extract VALID_PROVIDERS constant and isValidProvider helper (DRY) - allow empty arrays in bulk endpoints (return early success) - add accountIds string validation (non-empty strings only) --- src/cliproxy/account-manager.ts | 107 ++++++++++++++++++++ src/web-server/routes/account-routes.ts | 127 +++++++++++++++++++++++- 2 files changed, 231 insertions(+), 3 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 6cb11363..069a93f7 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -835,6 +835,113 @@ export function discoverExistingAccounts(): void { saveAccountsRegistry(freshRegistry); } +/** Result of bulk pause/resume operations */ +export interface BulkOperationResult { + succeeded: string[]; + failed: Array<{ id: string; reason: string }>; +} + +/** Result of solo account operation */ +export interface SoloOperationResult { + activated: string; + paused: string[]; +} + +/** Provider-level locks for preventing race conditions in solo mode */ +const providerLocks = new Map>(); + +/** + * Bulk pause multiple accounts + * Pauses each account, collecting successes and failures (no fail-fast) + */ +export function bulkPauseAccounts( + provider: CLIProxyProvider, + accountIds: string[] +): BulkOperationResult { + const result: BulkOperationResult = { succeeded: [], failed: [] }; + + for (const id of accountIds) { + const success = pauseAccount(provider, id); + if (success) { + result.succeeded.push(id); + } else { + result.failed.push({ id, reason: 'Account not found' }); + } + } + + return result; +} + +/** + * Bulk resume multiple accounts + * Resumes each account, collecting successes and failures (no fail-fast) + */ +export function bulkResumeAccounts( + provider: CLIProxyProvider, + accountIds: string[] +): BulkOperationResult { + const result: BulkOperationResult = { succeeded: [], failed: [] }; + + for (const id of accountIds) { + const success = resumeAccount(provider, id); + if (success) { + result.succeeded.push(id); + } else { + result.failed.push({ id, reason: 'Account not found' }); + } + } + + return result; +} + +/** + * Solo mode: activate one account, pause all others in same provider + * Per validation: auto-resumes target if paused, keeps default unchanged + */ +export async function soloAccount( + provider: CLIProxyProvider, + accountId: string +): Promise { + // Wait for any pending operation on this provider + const pending = providerLocks.get(provider); + if (pending) await pending; + + const operation = (async () => { + const accounts = getProviderAccounts(provider); + const targetAccount = accounts.find((a) => a.id === accountId); + + if (!targetAccount) { + return null; + } + + const result: SoloOperationResult = { activated: accountId, paused: [] }; + + // Resume target account if paused (per validation decision) + if (targetAccount.paused) { + resumeAccount(provider, accountId); + } + + // Pause all other accounts + for (const account of accounts) { + if (account.id !== accountId && !account.paused) { + const success = pauseAccount(provider, account.id); + if (success) { + result.paused.push(account.id); + } + } + } + + return result; + })(); + + providerLocks.set(provider, operation); + try { + return await operation; + } finally { + providerLocks.delete(provider); + } +} + /** * Get summary of all accounts across providers */ diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 841855b2..d87d6592 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -12,22 +12,40 @@ import { getAllAccountsSummary, setDefaultAccount as setCliproxyDefault, removeAccount as removeCliproxyAccount, + bulkPauseAccounts, + bulkResumeAccounts, + soloAccount, } from '../../cliproxy/account-manager'; -import { CLIProxyProvider } from '../../cliproxy/types'; +import type { CLIProxyProvider } from '../../cliproxy/types'; const router = Router(); const registry = new ProfileRegistry(); +/** Valid CLIProxy providers */ +const VALID_PROVIDERS: CLIProxyProvider[] = [ + 'gemini', + 'codex', + 'agy', + 'qwen', + 'iflow', + 'kiro', + 'ghcp', +]; + +/** Check if provider is valid */ +function isValidProvider(provider: string): provider is CLIProxyProvider { + return VALID_PROVIDERS.includes(provider as CLIProxyProvider); +} + /** Parse CLIProxy account key format: "provider:accountId" */ function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null { - const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp']; const colonIndex = key.indexOf(':'); if (colonIndex === -1) return null; const provider = key.slice(0, colonIndex) as CLIProxyProvider; const accountId = key.slice(colonIndex + 1); - if (!providers.includes(provider) || !accountId) return null; + if (!isValidProvider(provider) || !accountId) return null; return { provider, accountId }; } @@ -203,4 +221,107 @@ router.delete('/:name', (req: Request, res: Response): void => { } }); +/** + * POST /api/accounts/bulk-pause - Bulk pause multiple accounts + */ +router.post('/bulk-pause', (req: Request, res: Response): void => { + try { + const { provider, accountIds } = req.body; + + if (!provider || !Array.isArray(accountIds)) { + res.status(400).json({ error: 'Missing required fields: provider and accountIds (array)' }); + return; + } + + if (!isValidProvider(provider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + // Allow empty arrays - return early success + if (accountIds.length === 0) { + res.json({ succeeded: [], failed: [] }); + return; + } + + // Validate accountIds are non-empty strings + const invalidIds = accountIds.filter((id) => typeof id !== 'string' || id.trim().length === 0); + if (invalidIds.length > 0) { + res.status(400).json({ error: 'Invalid accountIds: must be non-empty strings' }); + return; + } + + const result = bulkPauseAccounts(provider, accountIds); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/accounts/bulk-resume - Bulk resume multiple accounts + */ +router.post('/bulk-resume', (req: Request, res: Response): void => { + try { + const { provider, accountIds } = req.body; + + if (!provider || !Array.isArray(accountIds)) { + res.status(400).json({ error: 'Missing required fields: provider and accountIds (array)' }); + return; + } + + if (!isValidProvider(provider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + // Allow empty arrays - return early success + if (accountIds.length === 0) { + res.json({ succeeded: [], failed: [] }); + return; + } + + // Validate accountIds are non-empty strings + const invalidIds = accountIds.filter((id) => typeof id !== 'string' || id.trim().length === 0); + if (invalidIds.length > 0) { + res.status(400).json({ error: 'Invalid accountIds: must be non-empty strings' }); + return; + } + + const result = bulkResumeAccounts(provider, accountIds); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/accounts/solo - Solo mode: activate one account, pause all others + */ +router.post('/solo', async (req: Request, res: Response): Promise => { + try { + const { provider, accountId } = req.body; + + if (!provider || !accountId) { + res.status(400).json({ error: 'Missing required fields: provider and accountId' }); + return; + } + + if (!isValidProvider(provider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + const result = await soloAccount(provider, accountId); + if (!result) { + res.status(404).json({ error: `Account not found: ${accountId}` }); + return; + } + + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; From 708661744fa8463ae919cb597bd9c43cca21c336 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 24 Jan 2026 12:22:56 -0500 Subject: [PATCH 02/21] fix(hooks): add stats invalidation to account control mutations - add cliproxy-stats query invalidation to pause/resume/solo mutations - ensures UI shows fresh stats after account state changes --- ui/src/hooks/use-cliproxy.ts | 73 ++++++++++++++++++++++++++++++++++++ ui/src/lib/api-client.ts | 24 ++++++++++++ 2 files changed, 97 insertions(+) diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 493fe6ea..be6dc613 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -127,6 +127,7 @@ export function usePauseAccount() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success('Account paused'); }, onError: (error: Error) => { @@ -144,6 +145,7 @@ export function useResumeAccount() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success('Account resumed'); }, onError: (error: Error) => { @@ -152,6 +154,77 @@ export function useResumeAccount() { }); } +export function useSoloAccount() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => + api.cliproxy.accounts.solo(provider, accountId), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); + const pausedCount = data.paused.length; + toast.success( + `Solo mode: paused ${pausedCount} other account${pausedCount !== 1 ? 's' : ''}` + ); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +export function useBulkPauseAccounts() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ provider, accountIds }: { provider: string; accountIds: string[] }) => + api.cliproxy.accounts.bulkPause(provider, accountIds), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); + toast.success( + `Paused ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}` + ); + if (data.failed.length > 0) { + toast.warning( + `${data.failed.length} account${data.failed.length !== 1 ? 's' : ''} failed to pause` + ); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +export function useBulkResumeAccounts() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ provider, accountIds }: { provider: string; accountIds: string[] }) => + api.cliproxy.accounts.bulkResume(provider, accountIds), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); + toast.success( + `Resumed ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}` + ); + if (data.failed.length > 0) { + toast.warning( + `${data.failed.length} account${data.failed.length !== 1 ? 's' : ''} failed to resume` + ); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + // OAuth flow hook export function useStartAuth() { const queryClient = useQueryClient(); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 445b9133..6f052997 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -415,6 +415,30 @@ export const api = { `/cliproxy/auth/accounts/${provider}/${accountId}/resume`, { method: 'POST' } ), + /** Solo mode: activate one account, pause all others */ + solo: (provider: string, accountId: string) => + request<{ activated: string; paused: string[] }>('/accounts/solo', { + method: 'POST', + body: JSON.stringify({ provider, accountId }), + }), + /** Bulk pause multiple accounts */ + bulkPause: (provider: string, accountIds: string[]) => + request<{ succeeded: string[]; failed: Array<{ id: string; reason: string }> }>( + '/accounts/bulk-pause', + { + method: 'POST', + body: JSON.stringify({ provider, accountIds }), + } + ), + /** Bulk resume multiple accounts */ + bulkResume: (provider: string, accountIds: string[]) => + request<{ succeeded: string[]; failed: Array<{ id: string; reason: string }> }>( + '/accounts/bulk-resume', + { + method: 'POST', + body: JSON.stringify({ provider, accountIds }), + } + ), }, // OAuth flow auth: { From 1427d36f869f8b0eb64d2c5d6893810515d2d4d4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 24 Jan 2026 12:23:04 -0500 Subject: [PATCH 03/21] feat(ui): add bulk account controls to provider editor - add BulkActionBar component for multi-select pause/resume - add AccountItem with individual pause toggle and solo button - fix solo button disabled logic for consistency - add selection state and bulk operations to AccountsSection --- .../cliproxy/provider-editor/account-item.tsx | 129 ++++++++++++------ .../provider-editor/accounts-section.tsx | 110 ++++++++++++++- .../provider-editor/bulk-action-bar.tsx | 67 +++++++++ .../cliproxy/provider-editor/index.tsx | 12 ++ .../provider-editor/model-config-tab.tsx | 24 ++++ .../cliproxy/provider-editor/types.ts | 22 +++ 6 files changed, 323 insertions(+), 41 deletions(-) create mode 100644 ui/src/components/cliproxy/provider-editor/bulk-action-bar.tsx diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 2aec131d..aa19af3c 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; +import { Switch } from '@/components/ui/switch'; +import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuContent, @@ -23,10 +25,10 @@ import { CheckCircle2, HelpCircle, Pause, - Play, AlertCircle, AlertTriangle, FolderCode, + Radio, } from 'lucide-react'; import { cn, @@ -93,10 +95,15 @@ export function AccountItem({ onSetDefault, onRemove, onPauseToggle, + onSoloMode, isRemoving, isPausingAccount, + isSoloingAccount, privacyMode, showQuota, + selectable, + selected, + onSelectChange, }: AccountItemProps) { // Fetch runtime stats to get actual lastUsedAt (more accurate than file state) const { data: stats } = useCliproxyStats(showQuota); @@ -123,11 +130,20 @@ export function AccountItem({
+ {/* Multi-select checkbox */} + {selectable && ( + onSelectChange?.(!!checked)} + aria-label={`Select ${account.email || account.id}`} + /> + )}
- - - - - - {!account.isDefault && ( - - - Set as default - - )} - {onPauseToggle && ( + {/* Inline controls: Solo button + Toggle switch */} +
+ {/* Solo mode button */} + {onSoloMode && ( + + + + + + Activate only this account + + + )} + + {/* Pause/Resume toggle switch */} + {onPauseToggle && ( + + + +
+ onPauseToggle(!checked)} + disabled={isPausingAccount} + aria-label={account.paused ? 'Resume account' : 'Pause account'} + className="scale-90" + /> +
+
+ + {account.paused ? 'Resume account' : 'Pause account'} + +
+
+ )} + + {/* Dropdown menu for other actions */} + + + + + + {!account.isDefault && ( + + + Set as default + + )} onPauseToggle(!account.paused)} - disabled={isPausingAccount} + className="text-destructive focus:text-destructive" + onClick={onRemove} + disabled={isRemoving} > - {account.paused ? ( - <> - - {isPausingAccount ? 'Resuming...' : 'Resume account'} - - ) : ( - <> - - {isPausingAccount ? 'Pausing...' : 'Pause account'} - - )} + + {isRemoving ? 'Removing...' : 'Remove account'} - )} - - - {isRemoving ? 'Removing...' : 'Remove account'} - - - + + +
{/* Quota bar - supports all providers with quota API */} diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx index f9d43ed8..d69f9b58 100644 --- a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx @@ -1,13 +1,16 @@ /** * Accounts Section Component - * Manages connected OAuth accounts for a provider + * Manages connected OAuth accounts for a provider with multi-select bulk actions */ +import { useState, useMemo, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; +import { Checkbox } from '@/components/ui/checkbox'; import { User, Plus, Globe } from 'lucide-react'; import { AccountItem } from './account-item'; +import { BulkActionBar } from './bulk-action-bar'; import type { OAuthAccount } from '@/lib/api-client'; interface AccountsSectionProps { @@ -16,9 +19,21 @@ interface AccountsSectionProps { onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; onPauseToggle?: (accountId: string, paused: boolean) => void; + /** Solo mode: activate one account, pause all others */ + onSoloMode?: (accountId: string) => void; + /** Bulk pause multiple accounts */ + onBulkPause?: (accountIds: string[]) => void; + /** Bulk resume multiple accounts */ + onBulkResume?: (accountIds: string[]) => void; isRemovingAccount?: boolean; /** Pause/resume mutation in progress */ isPausingAccount?: boolean; + /** Solo mode mutation in progress */ + isSoloingAccount?: boolean; + /** Bulk pause mutation in progress */ + isBulkPausing?: boolean; + /** Bulk resume mutation in progress */ + isBulkResuming?: boolean; privacyMode?: boolean; /** Show quota bars for accounts (only applicable for 'agy' provider) */ showQuota?: boolean; @@ -35,8 +50,14 @@ export function AccountsSection({ onSetDefault, onRemoveAccount, onPauseToggle, + onSoloMode, + onBulkPause, + onBulkResume, isRemovingAccount, isPausingAccount, + isSoloingAccount, + isBulkPausing, + isBulkResuming, privacyMode, showQuota, isKiro, @@ -44,10 +65,80 @@ export function AccountsSection({ onKiroNoIncognitoChange, kiroSettingsLoading, }: AccountsSectionProps) { + // Multi-select state - raw selection (may contain stale IDs) + const [rawSelectedIds, setRawSelectedIds] = useState>(new Set()); + + // Derive valid selections by filtering out stale IDs + const accountIds = useMemo(() => new Set(accounts.map((a) => a.id)), [accounts]); + const selectedIds = useMemo( + () => new Set([...rawSelectedIds].filter((id) => accountIds.has(id))), + [rawSelectedIds, accountIds] + ); + + // Enable multi-select when bulk actions are available + const isSelectable = !!(onBulkPause && onBulkResume); + + // Computed selection state + const selectedCount = selectedIds.size; + const allSelected = accounts.length > 0 && selectedIds.size === accounts.length; + const someSelected = selectedIds.size > 0 && selectedIds.size < accounts.length; + + // Selection handlers + const toggleSelect = useCallback((id: string) => { + setRawSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const selectAll = useCallback(() => { + setRawSelectedIds(new Set(accounts.map((a) => a.id))); + }, [accounts]); + + const deselectAll = useCallback(() => { + setRawSelectedIds(new Set()); + }, []); + + const toggleSelectAll = useCallback(() => { + if (allSelected) { + deselectAll(); + } else { + selectAll(); + } + }, [allSelected, selectAll, deselectAll]); + + // Bulk action handlers + const handleBulkPause = useCallback(() => { + if (onBulkPause && selectedIds.size > 0) { + onBulkPause(Array.from(selectedIds)); + // Clear selection after action (will be invalidated by mutation success) + setRawSelectedIds(new Set()); + } + }, [onBulkPause, selectedIds]); + + const handleBulkResume = useCallback(() => { + if (onBulkResume && selectedIds.size > 0) { + onBulkResume(Array.from(selectedIds)); + setRawSelectedIds(new Set()); + } + }, [onBulkResume, selectedIds]); + return (

+ {/* Select All checkbox */} + {isSelectable && accounts.length > 0 && ( + + )} Accounts {accounts.length > 0 && ( @@ -73,10 +164,15 @@ export function AccountsSection({ onPauseToggle={ onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined } + onSoloMode={onSoloMode ? () => onSoloMode(account.id) : undefined} isRemoving={isRemovingAccount} isPausingAccount={isPausingAccount} + isSoloingAccount={isSoloingAccount} privacyMode={privacyMode} showQuota={showQuota} + selectable={isSelectable} + selected={selectedIds.has(account.id)} + onSelectChange={() => toggleSelect(account.id)} /> ))}

@@ -88,6 +184,18 @@ export function AccountsSection({
)} + {/* Bulk Action Bar - shows when accounts selected */} + {isSelectable && ( + + )} + {/* Kiro-specific: Incognito browser setting - users complain "it keeps opening incognito" */} {isKiro && onKiroNoIncognitoChange && (
diff --git a/ui/src/components/cliproxy/provider-editor/bulk-action-bar.tsx b/ui/src/components/cliproxy/provider-editor/bulk-action-bar.tsx new file mode 100644 index 00000000..426c8c8f --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/bulk-action-bar.tsx @@ -0,0 +1,67 @@ +/** + * Bulk Action Bar Component + * Appears when accounts are selected for bulk pause/resume operations + */ + +import { Button } from '@/components/ui/button'; +import { Pause, Play, Loader2 } from 'lucide-react'; + +interface BulkActionBarProps { + selectedCount: number; + onPauseSelected: () => void; + onResumeSelected: () => void; + onClearSelection: () => void; + isPausing: boolean; + isResuming: boolean; +} + +export function BulkActionBar({ + selectedCount, + onPauseSelected, + onResumeSelected, + onClearSelection, + isPausing, + isResuming, +}: BulkActionBarProps) { + // Show bar when at least 1 account is selected (per validation decision) + if (selectedCount < 1) return null; + + const isLoading = isPausing || isResuming; + + return ( +
+ {selectedCount} selected + +
+ + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index b2d5b089..77672594 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -39,8 +39,14 @@ export function ProviderEditor({ onSetDefault, onRemoveAccount, onPauseToggle, + onSoloMode, + onBulkPause, + onBulkResume, isRemovingAccount, isPausingAccount, + isSoloingAccount, + isBulkPausing, + isBulkResuming, }: ProviderEditorProps) { const [customPresetOpen, setCustomPresetOpen] = useState(false); const { privacyMode } = usePrivacy(); @@ -203,8 +209,14 @@ export function ProviderEditor({ onSetDefault={onSetDefault} onRemoveAccount={onRemoveAccount} onPauseToggle={onPauseToggle} + onSoloMode={onSoloMode} + onBulkPause={onBulkPause} + onBulkResume={onBulkResume} isRemovingAccount={isRemovingAccount} isPausingAccount={isPausingAccount} + isSoloingAccount={isSoloingAccount} + isBulkPausing={isBulkPausing} + isBulkResuming={isBulkResuming} privacyMode={privacyMode} isRemoteMode={isRemoteMode} /> diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx index 51e46d55..62eeadd8 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -37,9 +37,21 @@ interface ModelConfigTabProps { onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; onPauseToggle?: (accountId: string, paused: boolean) => void; + /** Solo mode: activate one account, pause all others */ + onSoloMode?: (accountId: string) => void; isRemovingAccount?: boolean; /** Pause/resume mutation in progress */ isPausingAccount?: boolean; + /** Solo mode mutation in progress */ + isSoloingAccount?: boolean; + /** Bulk pause multiple accounts */ + onBulkPause?: (accountIds: string[]) => void; + /** Bulk resume multiple accounts */ + onBulkResume?: (accountIds: string[]) => void; + /** Bulk pause mutation in progress */ + isBulkPausing?: boolean; + /** Bulk resume mutation in progress */ + isBulkResuming?: boolean; privacyMode?: boolean; /** True if connected to remote CLIProxy (quota not available) */ isRemoteMode?: boolean; @@ -64,8 +76,14 @@ export function ModelConfigTab({ onSetDefault, onRemoveAccount, onPauseToggle, + onSoloMode, + onBulkPause, + onBulkResume, isRemovingAccount, isPausingAccount, + isSoloingAccount, + isBulkPausing, + isBulkResuming, privacyMode, isRemoteMode, }: ModelConfigTabProps) { @@ -140,8 +158,14 @@ export function ModelConfigTab({ onSetDefault={onSetDefault} onRemoveAccount={onRemoveAccount} onPauseToggle={onPauseToggle} + onSoloMode={onSoloMode} + onBulkPause={onBulkPause} + onBulkResume={onBulkResume} isRemovingAccount={isRemovingAccount} isPausingAccount={isPausingAccount} + isSoloingAccount={isSoloingAccount} + isBulkPausing={isBulkPausing} + isBulkResuming={isBulkResuming} privacyMode={privacyMode} showQuota={provider === 'agy' && !isRemoteMode} isKiro={isKiro} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 743a1bf3..a9023729 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -31,9 +31,21 @@ export interface ProviderEditorProps { onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; onPauseToggle?: (accountId: string, paused: boolean) => void; + /** Solo mode: activate one account, pause all others */ + onSoloMode?: (accountId: string) => void; + /** Bulk pause multiple accounts */ + onBulkPause?: (accountIds: string[]) => void; + /** Bulk resume multiple accounts */ + onBulkResume?: (accountIds: string[]) => void; isRemovingAccount?: boolean; /** Pause/resume mutation in progress */ isPausingAccount?: boolean; + /** Solo mode mutation in progress */ + isSoloingAccount?: boolean; + /** Bulk pause mutation in progress */ + isBulkPausing?: boolean; + /** Bulk resume mutation in progress */ + isBulkResuming?: boolean; } export interface AccountItemProps { @@ -41,12 +53,22 @@ export interface AccountItemProps { onSetDefault: () => void; onRemove: () => void; onPauseToggle?: (paused: boolean) => void; + /** Solo mode: activate this account, pause all others */ + onSoloMode?: () => void; isRemoving?: boolean; /** Pause/resume mutation in progress */ isPausingAccount?: boolean; + /** Solo mode mutation in progress */ + isSoloingAccount?: boolean; privacyMode?: boolean; /** Show quota bar (only for 'agy' provider) */ showQuota?: boolean; + /** Enable checkbox for multi-select */ + selectable?: boolean; + /** Whether this account is currently selected */ + selected?: boolean; + /** Called when checkbox is toggled */ + onSelectChange?: (selected: boolean) => void; } export interface ModelMappingValues { From 87226e05c40be83299187b35ba1ca4ca2113fbe3 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 24 Jan 2026 12:23:12 -0500 Subject: [PATCH 04/21] feat(ui): add expandable provider cards with account controls - add ExpandableAccountList for inline account management - add expand/collapse to ProviderCard with smooth transitions - wire pause/resume/solo mutations to auth-monitor index - add paused field to AccountRow type for UI display --- .../components/expandable-account-list.tsx | 83 ++++++++++ .../auth-monitor/components/provider-card.tsx | 144 ++++++++++++------ .../monitoring/auth-monitor/hooks.ts | 1 + .../monitoring/auth-monitor/index.tsx | 26 ++++ .../monitoring/auth-monitor/types.ts | 2 + 5 files changed, 213 insertions(+), 43 deletions(-) create mode 100644 ui/src/components/monitoring/auth-monitor/components/expandable-account-list.tsx diff --git a/ui/src/components/monitoring/auth-monitor/components/expandable-account-list.tsx b/ui/src/components/monitoring/auth-monitor/components/expandable-account-list.tsx new file mode 100644 index 00000000..8f8627bf --- /dev/null +++ b/ui/src/components/monitoring/auth-monitor/components/expandable-account-list.tsx @@ -0,0 +1,83 @@ +/** + * Expandable Account List - Account rows with toggle and solo controls + * Used in ProviderCard when expanded to show individual account controls + */ + +import { Radio } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { cleanEmail } from '../utils'; +import type { AccountRow } from '../types'; + +interface ExpandableAccountListProps { + accounts: AccountRow[]; + privacyMode: boolean; + onPauseToggle: (accountId: string, paused: boolean) => void; + onSoloMode: (accountId: string) => void; + isPausingAccount?: boolean; + isSoloingAccount?: boolean; +} + +export function ExpandableAccountList({ + accounts, + privacyMode, + onPauseToggle, + onSoloMode, + isPausingAccount, + isSoloingAccount, +}: ExpandableAccountListProps) { + return ( +
+ {accounts.map((acc) => ( +
+
+
+ + {privacyMode ? '••••••' : cleanEmail(acc.email)} + +
+
+ + + + + + + Solo mode - activate this, pause others + + + + { + onPauseToggle(acc.id, !checked); + }} + disabled={isPausingAccount} + className="scale-75" + onClick={(e) => e.stopPropagation()} + /> +
+
+ ))} +
+ ); +} diff --git a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx index 56faedbc..56818c73 100644 --- a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx +++ b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx @@ -1,9 +1,11 @@ /** - * ProviderCard - Provider status card with account color dots and stats + * ProviderCard - Provider status card with expandable account controls + * Click to expand and show individual account toggle/solo buttons */ import type React from 'react'; -import { ChevronRight, AlertTriangle } from 'lucide-react'; +import { useState } from 'react'; +import { ChevronRight, ChevronDown, AlertTriangle } from 'lucide-react'; import { cn, STATUS_COLORS } from '@/lib/utils'; import { PROVIDER_COLORS } from '@/lib/provider-config'; import { ProviderIcon } from '@/components/shared/provider-icon'; @@ -11,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import type { ProviderStats } from '../types'; import { getSuccessRate, cleanEmail } from '../utils'; import { InlineStatsBadge } from './inline-stats-badge'; +import { ExpandableAccountList } from './expandable-account-list'; interface ProviderCardProps { stats: ProviderStats; @@ -19,6 +22,10 @@ interface ProviderCardProps { onSelect: () => void; onMouseEnter: () => void; onMouseLeave: () => void; + onPauseToggle?: (accountId: string, paused: boolean) => void; + onSoloMode?: (accountId: string) => void; + isPausingAccount?: boolean; + isSoloingAccount?: boolean; } export function ProviderCard({ @@ -28,15 +35,32 @@ export function ProviderCard({ onSelect, onMouseEnter, onMouseLeave, + onPauseToggle, + onSoloMode, + isPausingAccount, + isSoloingAccount, }: ProviderCardProps) { + const [isExpanded, setIsExpanded] = useState(false); const successRate = getSuccessRate(stats.successCount, stats.failureCount); const providerColor = PROVIDER_COLORS[stats.provider.toLowerCase()] || '#6b7280'; + // Only expandable if account control callbacks are provided + const isExpandable = !!(onPauseToggle && onSoloMode); + + const handleClick = () => { + if (isExpandable) { + setIsExpanded(!isExpanded); + } else { + onSelect(); + } + }; + return (
- + {isExpandable ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )}
@@ -104,40 +141,61 @@ export function ProviderCard({
{/* Account color dots with warning for agy accounts missing projectId */} -
- {stats.accounts.slice(0, 5).map((acc) => { - const isMissingProjectId = stats.provider === 'agy' && !acc.projectId; - return ( -
-
- {isMissingProjectId && ( - - - - - - - Missing Project ID - re-add account to fix - - - - )} -
- ); - })} - {stats.accounts.length > 5 && ( - - +{stats.accounts.length - 5} - - )} -
+ {!isExpanded && ( +
+ {stats.accounts.slice(0, 5).map((acc) => { + const isMissingProjectId = stats.provider === 'agy' && !acc.projectId; + return ( +
+
+ {isMissingProjectId && ( + + + + + + + Missing Project ID - re-add account to fix + + + + )} +
+ ); + })} + {stats.accounts.length > 5 && ( + + +{stats.accounts.length - 5} + + )} +
+ )} + + {/* Expandable account list */} + {isExpandable && ( +
+ +
+ )} ); } diff --git a/ui/src/components/monitoring/auth-monitor/hooks.ts b/ui/src/components/monitoring/auth-monitor/hooks.ts index dd19b8bc..4af76b84 100644 --- a/ui/src/components/monitoring/auth-monitor/hooks.ts +++ b/ui/src/components/monitoring/auth-monitor/hooks.ts @@ -100,6 +100,7 @@ export function useAuthMonitorData(): AuthMonitorData { lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt, color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length], projectId: account.projectId, + paused: account.paused, }; accountsList.push(row); providerData.accounts.push(row); diff --git a/ui/src/components/monitoring/auth-monitor/index.tsx b/ui/src/components/monitoring/auth-monitor/index.tsx index d43e1d48..6cd55d83 100644 --- a/ui/src/components/monitoring/auth-monitor/index.tsx +++ b/ui/src/components/monitoring/auth-monitor/index.tsx @@ -9,6 +9,7 @@ import { STATUS_COLORS } from '@/lib/utils'; import { Skeleton } from '@/components/ui/skeleton'; import { AccountFlowViz } from '@/components/account-flow-viz'; import { usePrivacy } from '@/contexts/privacy-context'; +import { usePauseAccount, useResumeAccount, useSoloAccount } from '@/hooks/use-cliproxy'; import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react'; import { useAuthMonitorData } from './hooks'; @@ -33,11 +34,30 @@ export function AuthMonitor() { const [selectedProvider, setSelectedProvider] = useState(null); const [hoveredProvider, setHoveredProvider] = useState(null); + // Account control mutations + const pauseMutation = usePauseAccount(); + const resumeMutation = useResumeAccount(); + const soloMutation = useSoloAccount(); + // Get selected provider data for detail view const selectedProviderData = selectedProvider ? providerStats.find((ps) => ps.provider === selectedProvider) : null; + const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => { + if (pauseMutation.isPending || resumeMutation.isPending) return; + if (paused) { + pauseMutation.mutate({ provider, accountId }); + } else { + resumeMutation.mutate({ provider, accountId }); + } + }; + + const handleSoloMode = (provider: string, accountId: string) => { + if (soloMutation.isPending) return; + soloMutation.mutate({ provider, accountId }); + }; + if (isLoading) { return (
@@ -137,6 +157,12 @@ export function AuthMonitor() { onSelect={() => setSelectedProvider(ps.provider)} onMouseEnter={() => setHoveredProvider(ps.provider)} onMouseLeave={() => setHoveredProvider(null)} + onPauseToggle={(accountId, paused) => + handlePauseToggle(ps.provider, accountId, paused) + } + onSoloMode={(accountId) => handleSoloMode(ps.provider, accountId)} + isPausingAccount={pauseMutation.isPending || resumeMutation.isPending} + isSoloingAccount={soloMutation.isPending} /> ))}
diff --git a/ui/src/components/monitoring/auth-monitor/types.ts b/ui/src/components/monitoring/auth-monitor/types.ts index 408b1e71..1490c134 100644 --- a/ui/src/components/monitoring/auth-monitor/types.ts +++ b/ui/src/components/monitoring/auth-monitor/types.ts @@ -14,6 +14,8 @@ export interface AccountRow { color: string; /** GCP Project ID (Antigravity only) - read-only */ projectId?: string; + /** Whether account is paused (skipped in quota rotation) */ + paused?: boolean; } export interface ProviderStats { From 118ce46e7287aa929f489e218ef30609fb1348c8 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 24 Jan 2026 12:23:18 -0500 Subject: [PATCH 05/21] refactor(ui): update cliproxy page for account control flow --- ui/src/pages/cliproxy.tsx | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index ec6daa57..3a1ed931 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -24,6 +24,9 @@ import { useRemoveAccount, usePauseAccount, useResumeAccount, + useSoloAccount, + useBulkPauseAccounts, + useBulkResumeAccounts, useDeleteVariant, } from '@/hooks/use-cliproxy'; import type { AuthStatus, Variant } from '@/lib/api-client'; @@ -185,6 +188,9 @@ export function CliproxyPage() { const removeMutation = useRemoveAccount(); const pauseMutation = usePauseAccount(); const resumeMutation = useResumeAccount(); + const soloMutation = useSoloAccount(); + const bulkPauseMutation = useBulkPauseAccounts(); + const bulkResumeMutation = useBulkResumeAccounts(); const deleteMutation = useDeleteVariant(); // Selection state: either a provider or a variant @@ -232,6 +238,21 @@ export function CliproxyPage() { } }; + const handleSoloMode = (provider: string, accountId: string) => { + if (soloMutation.isPending) return; + soloMutation.mutate({ provider, accountId }); + }; + + const handleBulkPause = (provider: string, accountIds: string[]) => { + if (bulkPauseMutation.isPending) return; + bulkPauseMutation.mutate({ provider, accountIds }); + }; + + const handleBulkResume = (provider: string, accountIds: string[]) => { + if (bulkResumeMutation.isPending) return; + bulkResumeMutation.mutate({ provider, accountIds }); + }; + const handleSelectProvider = (provider: string) => { setSelectedProvider(provider); setSelectedVariant(null); @@ -380,8 +401,16 @@ export function CliproxyPage() { onPauseToggle={(accountId, paused) => handlePauseToggle(selectedVariantData.provider, accountId, paused) } + onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)} + onBulkPause={(accountIds) => handleBulkPause(selectedVariantData.provider, accountIds)} + onBulkResume={(accountIds) => + handleBulkResume(selectedVariantData.provider, accountIds) + } isRemovingAccount={removeMutation.isPending} isPausingAccount={pauseMutation.isPending || resumeMutation.isPending} + isSoloingAccount={soloMutation.isPending} + isBulkPausing={bulkPauseMutation.isPending} + isBulkResuming={bulkResumeMutation.isPending} /> ) : selectedStatus ? ( handlePauseToggle(selectedStatus.provider, accountId, paused) } + onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)} + onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)} + onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)} isRemovingAccount={removeMutation.isPending} isPausingAccount={pauseMutation.isPending || resumeMutation.isPending} + isSoloingAccount={soloMutation.isPending} + isBulkPausing={bulkPauseMutation.isPending} + isBulkResuming={bulkResumeMutation.isPending} /> ) : ( setWizardOpen(true)} /> From 56dfb2429bc93772e260aa6ecaed50ebf714be06 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 24 Jan 2026 12:33:37 -0500 Subject: [PATCH 06/21] feat(ui): add visible pause toggle button to account item cards - add pause/play button left of user avatar - paused accounts show play icon (green) and dimmed card - active accounts show pause icon - includes loading spinner and tooltip --- .../cliproxy/provider-editor/account-item.tsx | 157 ++++++++---------- 1 file changed, 68 insertions(+), 89 deletions(-) diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index aa19af3c..33ee1c46 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -6,8 +6,6 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; -import { Switch } from '@/components/ui/switch'; -import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuContent, @@ -25,10 +23,10 @@ import { CheckCircle2, HelpCircle, Pause, + Play, AlertCircle, AlertTriangle, FolderCode, - Radio, } from 'lucide-react'; import { cn, @@ -95,15 +93,10 @@ export function AccountItem({ onSetDefault, onRemove, onPauseToggle, - onSoloMode, isRemoving, isPausingAccount, - isSoloingAccount, privacyMode, showQuota, - selectable, - selected, - onSelectChange, }: AccountItemProps) { // Fetch runtime stats to get actual lastUsedAt (more accurate than file state) const { data: stats } = useCliproxyStats(showQuota); @@ -131,18 +124,37 @@ export function AccountItem({ className={cn( 'flex flex-col gap-2 p-3 rounded-lg border transition-colors', account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30', - account.paused && 'opacity-50 border-muted' + account.paused && 'opacity-75' )} >
- {/* Multi-select checkbox */} - {selectable && ( - onSelectChange?.(!!checked)} - aria-label={`Select ${account.email || account.id}`} - /> + {/* Pause/Resume toggle button - visible left of avatar */} + {onPauseToggle && ( + + + + + + + {account.paused ? 'Resume account' : 'Pause account'} + + + )}
- {/* Inline controls: Solo button + Toggle switch */} -
- {/* Solo mode button */} - {onSoloMode && ( - - - - - - Activate only this account - - - )} - - {/* Pause/Resume toggle switch */} - {onPauseToggle && ( - - - -
- onPauseToggle(!checked)} - disabled={isPausingAccount} - aria-label={account.paused ? 'Resume account' : 'Pause account'} - className="scale-90" - /> -
-
- - {account.paused ? 'Resume account' : 'Pause account'} - -
-
- )} - - {/* Dropdown menu for other actions */} - - - - - - {!account.isDefault && ( - - - Set as default - - )} - - - {isRemoving ? 'Removing...' : 'Remove account'} + + + + + + {!account.isDefault && ( + + + Set as default - - -
+ )} + {onPauseToggle && ( + onPauseToggle(!account.paused)} + disabled={isPausingAccount} + > + {account.paused ? ( + <> + + {isPausingAccount ? 'Resuming...' : 'Resume account'} + + ) : ( + <> + + {isPausingAccount ? 'Pausing...' : 'Pause account'} + + )} + + )} + + + {isRemoving ? 'Removing...' : 'Remove account'} + + +
{/* Quota bar - supports all providers with quota API */} From 2d42327a891ad1c964df67780f3eccf31b7636e1 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 24 Jan 2026 12:37:55 -0500 Subject: [PATCH 07/21] revert(ui): restore original provider card navigation behavior Reverts the expandable ProviderCard design back to click-to-navigate. The pause toggle feature is preserved in AccountItem cards in Provider Editor, not in the Live Account Monitor overview. - Remove expandable account list component - Remove unused pause/resume/solo mutations from AuthMonitor - Restore original ProviderCard props interface - Keep acc.paused opacity-50 for visual indication on dots --- .../components/expandable-account-list.tsx | 83 ---------- .../auth-monitor/components/provider-card.tsx | 144 ++++++------------ .../monitoring/auth-monitor/index.tsx | 26 ---- 3 files changed, 43 insertions(+), 210 deletions(-) delete mode 100644 ui/src/components/monitoring/auth-monitor/components/expandable-account-list.tsx diff --git a/ui/src/components/monitoring/auth-monitor/components/expandable-account-list.tsx b/ui/src/components/monitoring/auth-monitor/components/expandable-account-list.tsx deleted file mode 100644 index 8f8627bf..00000000 --- a/ui/src/components/monitoring/auth-monitor/components/expandable-account-list.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Expandable Account List - Account rows with toggle and solo controls - * Used in ProviderCard when expanded to show individual account controls - */ - -import { Radio } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Switch } from '@/components/ui/switch'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { cn } from '@/lib/utils'; -import { cleanEmail } from '../utils'; -import type { AccountRow } from '../types'; - -interface ExpandableAccountListProps { - accounts: AccountRow[]; - privacyMode: boolean; - onPauseToggle: (accountId: string, paused: boolean) => void; - onSoloMode: (accountId: string) => void; - isPausingAccount?: boolean; - isSoloingAccount?: boolean; -} - -export function ExpandableAccountList({ - accounts, - privacyMode, - onPauseToggle, - onSoloMode, - isPausingAccount, - isSoloingAccount, -}: ExpandableAccountListProps) { - return ( -
- {accounts.map((acc) => ( -
-
-
- - {privacyMode ? '••••••' : cleanEmail(acc.email)} - -
-
- - - - - - - Solo mode - activate this, pause others - - - - { - onPauseToggle(acc.id, !checked); - }} - disabled={isPausingAccount} - className="scale-75" - onClick={(e) => e.stopPropagation()} - /> -
-
- ))} -
- ); -} diff --git a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx index 56818c73..59af0ac4 100644 --- a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx +++ b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx @@ -1,11 +1,9 @@ /** - * ProviderCard - Provider status card with expandable account controls - * Click to expand and show individual account toggle/solo buttons + * ProviderCard - Provider status card with account color dots and stats */ import type React from 'react'; -import { useState } from 'react'; -import { ChevronRight, ChevronDown, AlertTriangle } from 'lucide-react'; +import { ChevronRight, AlertTriangle } from 'lucide-react'; import { cn, STATUS_COLORS } from '@/lib/utils'; import { PROVIDER_COLORS } from '@/lib/provider-config'; import { ProviderIcon } from '@/components/shared/provider-icon'; @@ -13,7 +11,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import type { ProviderStats } from '../types'; import { getSuccessRate, cleanEmail } from '../utils'; import { InlineStatsBadge } from './inline-stats-badge'; -import { ExpandableAccountList } from './expandable-account-list'; interface ProviderCardProps { stats: ProviderStats; @@ -22,10 +19,6 @@ interface ProviderCardProps { onSelect: () => void; onMouseEnter: () => void; onMouseLeave: () => void; - onPauseToggle?: (accountId: string, paused: boolean) => void; - onSoloMode?: (accountId: string) => void; - isPausingAccount?: boolean; - isSoloingAccount?: boolean; } export function ProviderCard({ @@ -35,32 +28,15 @@ export function ProviderCard({ onSelect, onMouseEnter, onMouseLeave, - onPauseToggle, - onSoloMode, - isPausingAccount, - isSoloingAccount, }: ProviderCardProps) { - const [isExpanded, setIsExpanded] = useState(false); const successRate = getSuccessRate(stats.successCount, stats.failureCount); const providerColor = PROVIDER_COLORS[stats.provider.toLowerCase()] || '#6b7280'; - // Only expandable if account control callbacks are provided - const isExpandable = !!(onPauseToggle && onSoloMode); - - const handleClick = () => { - if (isExpandable) { - setIsExpanded(!isExpanded); - } else { - onSelect(); - } - }; - return (
- {isExpandable ? ( - isExpanded ? ( - - ) : ( - - ) - ) : ( - - )} +
@@ -141,61 +104,40 @@ export function ProviderCard({
{/* Account color dots with warning for agy accounts missing projectId */} - {!isExpanded && ( -
- {stats.accounts.slice(0, 5).map((acc) => { - const isMissingProjectId = stats.provider === 'agy' && !acc.projectId; - return ( -
-
- {isMissingProjectId && ( - - - - - - - Missing Project ID - re-add account to fix - - - - )} -
- ); - })} - {stats.accounts.length > 5 && ( - - +{stats.accounts.length - 5} - - )} -
- )} - - {/* Expandable account list */} - {isExpandable && ( -
- -
- )} +
+ {stats.accounts.slice(0, 5).map((acc) => { + const isMissingProjectId = stats.provider === 'agy' && !acc.projectId; + return ( +
+
+ {isMissingProjectId && ( + + + + + + + Missing Project ID - re-add account to fix + + + + )} +
+ ); + })} + {stats.accounts.length > 5 && ( + + +{stats.accounts.length - 5} + + )} +
); } diff --git a/ui/src/components/monitoring/auth-monitor/index.tsx b/ui/src/components/monitoring/auth-monitor/index.tsx index 6cd55d83..d43e1d48 100644 --- a/ui/src/components/monitoring/auth-monitor/index.tsx +++ b/ui/src/components/monitoring/auth-monitor/index.tsx @@ -9,7 +9,6 @@ import { STATUS_COLORS } from '@/lib/utils'; import { Skeleton } from '@/components/ui/skeleton'; import { AccountFlowViz } from '@/components/account-flow-viz'; import { usePrivacy } from '@/contexts/privacy-context'; -import { usePauseAccount, useResumeAccount, useSoloAccount } from '@/hooks/use-cliproxy'; import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react'; import { useAuthMonitorData } from './hooks'; @@ -34,30 +33,11 @@ export function AuthMonitor() { const [selectedProvider, setSelectedProvider] = useState(null); const [hoveredProvider, setHoveredProvider] = useState(null); - // Account control mutations - const pauseMutation = usePauseAccount(); - const resumeMutation = useResumeAccount(); - const soloMutation = useSoloAccount(); - // Get selected provider data for detail view const selectedProviderData = selectedProvider ? providerStats.find((ps) => ps.provider === selectedProvider) : null; - const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => { - if (pauseMutation.isPending || resumeMutation.isPending) return; - if (paused) { - pauseMutation.mutate({ provider, accountId }); - } else { - resumeMutation.mutate({ provider, accountId }); - } - }; - - const handleSoloMode = (provider: string, accountId: string) => { - if (soloMutation.isPending) return; - soloMutation.mutate({ provider, accountId }); - }; - if (isLoading) { return (
@@ -157,12 +137,6 @@ export function AuthMonitor() { onSelect={() => setSelectedProvider(ps.provider)} onMouseEnter={() => setHoveredProvider(ps.provider)} onMouseLeave={() => setHoveredProvider(null)} - onPauseToggle={(accountId, paused) => - handlePauseToggle(ps.provider, accountId, paused) - } - onSoloMode={(accountId) => handleSoloMode(ps.provider, accountId)} - isPausingAccount={pauseMutation.isPending || resumeMutation.isPending} - isSoloingAccount={soloMutation.isPending} /> ))}
From 56dfada31c36425a11d4eff397c925980a43b94c Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 24 Jan 2026 13:01:30 -0500 Subject: [PATCH 08/21] fix(ui): add pause toggle to flow viz account cards, remove dropdown redundancy - Add visible pause/resume toggle button to AccountCard in flow-viz (appears on hover or when paused, with tooltip) - Pass pause toggle props from AuthMonitor through AccountFlowViz - Remove redundant pause/resume from account-item dropdown menu (keeps only "Set as default" and "Remove account") - Add paused state to AccountData type with opacity indication --- .../account/flow-viz/account-card.tsx | 44 ++++++++++++++++++- ui/src/components/account/flow-viz/index.tsx | 9 +++- ui/src/components/account/flow-viz/types.ts | 3 ++ .../cliproxy/provider-editor/account-item.tsx | 18 -------- .../monitoring/auth-monitor/index.tsx | 16 +++++++ 5 files changed, 69 insertions(+), 21 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 7f7d2bd7..29dff515 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -10,9 +10,10 @@ import { getMinClaudeQuota, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; -import { GripVertical, Loader2, Clock, Pause } from 'lucide-react'; +import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react'; import { useAccountQuota } from '@/hooks/use-cliproxy-stats'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Button } from '@/components/ui/button'; import type { AccountData, DragOffset } from './types'; import { cleanEmail } from './utils'; @@ -34,6 +35,8 @@ interface AccountCardProps { onPointerDown: (e: React.PointerEvent) => void; onPointerMove: (e: React.PointerEvent) => void; onPointerUp: () => void; + onPauseToggle?: (accountId: string, paused: boolean) => void; + isPausingAccount?: boolean; } const BORDER_SIDE_MAP: Record = { @@ -77,6 +80,8 @@ export function AccountCard({ onPointerDown, onPointerMove, onPointerUp, + onPauseToggle, + isPausingAccount, }: AccountCardProps) { const borderSide = BORDER_SIDE_MAP[zone]; const borderColor = getBorderColorStyle(zone, account.color); @@ -109,13 +114,48 @@ export function AccountCard({ borderSide, 'select-none touch-none', isHovered && 'bg-muted/50 dark:bg-zinc-800/60', - isDragging && 'cursor-grabbing shadow-xl scale-105 z-50' + isDragging && 'cursor-grabbing shadow-xl scale-105 z-50', + account.paused && 'opacity-60' )} style={{ ...borderColor, transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, }} > + {/* Pause toggle button - visible on hover or when paused */} + {onPauseToggle && ( + + + + + + + {account.paused ? 'Resume account' : 'Pause account'} + + + + )}
(null); const svgRef = useRef(null); const [hoveredAccount, setHoveredAccount] = useState(null); @@ -112,6 +117,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { onPointerDown={(e) => handlePointerDown(account.id, e)} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} + onPauseToggle={onPauseToggle} + isPausingAccount={isPausingAccount} /> ); }); diff --git a/ui/src/components/account/flow-viz/types.ts b/ui/src/components/account/flow-viz/types.ts index b6e5ca33..423155b1 100644 --- a/ui/src/components/account/flow-viz/types.ts +++ b/ui/src/components/account/flow-viz/types.ts @@ -16,6 +16,7 @@ export interface AccountData { failureCount: number; lastUsedAt?: string; color: string; + paused?: boolean; } export interface ProviderData { @@ -28,6 +29,8 @@ export interface ProviderData { export interface AccountFlowVizProps { providerData: ProviderData; onBack?: () => void; + onPauseToggle?: (accountId: string, paused: boolean) => void; + isPausingAccount?: boolean; } export interface ConnectionEvent { diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 33ee1c46..afc9ac3e 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -267,24 +267,6 @@ export function AccountItem({ Set as default )} - {onPauseToggle && ( - onPauseToggle(!account.paused)} - disabled={isPausingAccount} - > - {account.paused ? ( - <> - - {isPausingAccount ? 'Resuming...' : 'Resume account'} - - ) : ( - <> - - {isPausingAccount ? 'Pausing...' : 'Pause account'} - - )} - - )} (null); const [hoveredProvider, setHoveredProvider] = useState(null); + // Account control mutations for flow viz + const pauseMutation = usePauseAccount(); + const resumeMutation = useResumeAccount(); + // Get selected provider data for detail view const selectedProviderData = selectedProvider ? providerStats.find((ps) => ps.provider === selectedProvider) : null; + const handlePauseToggle = (accountId: string, paused: boolean) => { + if (!selectedProvider || pauseMutation.isPending || resumeMutation.isPending) return; + if (paused) { + pauseMutation.mutate({ provider: selectedProvider, accountId }); + } else { + resumeMutation.mutate({ provider: selectedProvider, accountId }); + } + }; + if (isLoading) { return (
@@ -121,6 +135,8 @@ export function AuthMonitor() { setSelectedProvider(null)} + onPauseToggle={handlePauseToggle} + isPausingAccount={pauseMutation.isPending || resumeMutation.isPending} /> ) : (
From eb053425c074ab150f67181ba7cf62668e0e7b49 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 16:23:54 -0500 Subject: [PATCH 09/21] fix(ui): improve pause toggle position in flow-viz account cards - move pause toggle inline with email (was absolute positioned) - always visible with subtle styling (not hover-only) - amber highlight when paused for quick visual scan --- .../account/flow-viz/account-card.tsx | 69 +++++++++---------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 29dff515..569d640c 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -122,42 +122,8 @@ export function AccountCard({ transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, }} > - {/* Pause toggle button - visible on hover or when paused */} - {onPauseToggle && ( - - - - - - - {account.paused ? 'Resume account' : 'Pause account'} - - - - )} -
+
{cleanEmail(account.email)} + {onPauseToggle && ( + + + + + + + {account.paused ? 'Resume account' : 'Pause account'} + + + + )}
Date: Sun, 25 Jan 2026 16:25:08 -0500 Subject: [PATCH 10/21] fix(ui): improve bulk actions UX in provider editor - move BulkActionBar above account list (was at bottom) - only show bar when selections exist (cleaner default) - add selection checkbox to each AccountItem - highlight selected accounts with ring + background --- .../cliproxy/provider-editor/account-item.tsx | 23 +++++++++++++++++- .../provider-editor/accounts-section.tsx | 24 +++++++++---------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index afc9ac3e..f0f4aed6 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -27,6 +27,7 @@ import { AlertCircle, AlertTriangle, FolderCode, + Check, } from 'lucide-react'; import { cn, @@ -97,6 +98,9 @@ export function AccountItem({ isPausingAccount, privacyMode, showQuota, + selectable, + selected, + onSelectChange, }: AccountItemProps) { // Fetch runtime stats to get actual lastUsedAt (more accurate than file state) const { data: stats } = useCliproxyStats(showQuota); @@ -124,11 +128,28 @@ export function AccountItem({ className={cn( 'flex flex-col gap-2 p-3 rounded-lg border transition-colors', account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30', - account.paused && 'opacity-75' + account.paused && 'opacity-75', + selected && 'ring-2 ring-primary/50 bg-primary/5' )} >
+ {/* Selection checkbox for bulk actions */} + {selectable && ( + + )} {/* Pause/Resume toggle button - visible left of avatar */} {onPauseToggle && ( diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx index d69f9b58..64ed3ef5 100644 --- a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx @@ -153,6 +153,18 @@ export function AccountsSection({
+ {/* Bulk Action Bar - shows when accounts selected (positioned at TOP for visibility) */} + {isSelectable && selectedCount > 0 && ( + + )} + {accounts.length > 0 ? (
{accounts.map((account) => ( @@ -184,18 +196,6 @@ export function AccountsSection({
)} - {/* Bulk Action Bar - shows when accounts selected */} - {isSelectable && ( - - )} - {/* Kiro-specific: Incognito browser setting - users complain "it keeps opening incognito" */} {isKiro && onKiroNoIncognitoChange && (
From 9516e71f17c112eb71d7e0283e5c9d8c654fa2f9 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 16:35:35 -0500 Subject: [PATCH 11/21] fix(ui): show Claude reset time for quota display (not earliest) - add getClaudeResetTime to match getMinClaudeQuota logic - weekly Claude resets now show absolute date (01/27, 12:07) - daily Gemini resets no longer overshadow Claude weekly limits --- .../account/flow-viz/account-card.tsx | 4 +- .../cliproxy/provider-editor/account-item.tsx | 4 +- ui/src/lib/utils.ts | 53 ++++++++++++++++--- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 569d640c..ac86917b 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -6,7 +6,7 @@ import { cn, sortModelsByPriority, formatResetTime, - getEarliestResetTime, + getClaudeResetTime, getMinClaudeQuota, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -226,7 +226,7 @@ export function AccountCard({
))} {(() => { - const resetTime = getEarliestResetTime(quota?.models || []); + const resetTime = getClaudeResetTime(quota?.models || []); return resetTime ? (
diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index f0f4aed6..8126989a 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -33,7 +33,7 @@ import { cn, sortModelsByPriority, formatResetTime, - getEarliestResetTime, + getClaudeResetTime, getMinClaudeQuota, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -121,7 +121,7 @@ export function AccountItem({ // Get earliest reset time const nextReset = - quota?.success && quota.models.length > 0 ? getEarliestResetTime(quota.models) : null; + quota?.success && quota.models.length > 0 ? getClaudeResetTime(quota.models) : null; return (
=24h (weekly limits) */ export function formatResetTime(resetTime: string | null): string | null { if (!resetTime) return null; @@ -92,8 +92,19 @@ export function formatResetTime(resetTime: string | null): string | null { if (diff <= 0) return 'soon'; const hours = Math.floor(diff / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + // Weekly/long resets: show absolute date (e.g., "01/27, 12:07") + if (hours >= 24) { + return reset.toLocaleDateString(undefined, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + } + + // Daily resets: show relative time + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); if (hours > 0) return `in ${hours}h ${minutes}m`; return `in ${minutes}m`; } catch { @@ -117,6 +128,16 @@ export function getEarliestResetTime( ); } +/** + * Filter to get Claude models from a model array + */ +function filterClaudeModels(models: T[]): T[] { + return models.filter((m) => { + const name = (m.displayName || m.name || '').toLowerCase(); + return name.includes('claude'); + }); +} + /** * Calculate the minimum quota percentage from Claude models (primary usage). * Falls back to minimum of all models if no Claude models exist. @@ -127,11 +148,7 @@ export function getMinClaudeQuota< >(models: T[]): number | null { if (models.length === 0) return null; - const claudeModels = models.filter((m) => { - const name = (m.displayName || m.name || '').toLowerCase(); - return name.includes('claude'); - }); - + const claudeModels = filterClaudeModels(models); const targetModels = claudeModels.length > 0 ? claudeModels : models; const percentages = targetModels .map((m) => m.percentage) @@ -140,3 +157,25 @@ export function getMinClaudeQuota< if (percentages.length === 0) return null; return Math.min(...percentages); } + +/** + * Get reset time for Claude models (matches getMinClaudeQuota logic). + * Shows when the Claude quota will reset, not just earliest across all models. + */ +export function getClaudeResetTime< + T extends { name: string; displayName?: string; resetTime: string | null }, +>(models: T[]): string | null { + if (models.length === 0) return null; + + const claudeModels = filterClaudeModels(models); + const targetModels = claudeModels.length > 0 ? claudeModels : models; + + return targetModels.reduce( + (earliest, m) => { + if (!m.resetTime) return earliest; + if (!earliest) return m.resetTime; + return new Date(m.resetTime) < new Date(earliest) ? m.resetTime : earliest; + }, + null as string | null + ); +} From 1f323f082c92bd8e740fa519b196f2ed957a0b68 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 16:48:45 -0500 Subject: [PATCH 12/21] fix(ui): show 0% quota when Claude/GPT models exhausted - include GPT models in primary quota filter (not just Claude) - return 0% when Claude/GPT missing from API (exhausted, not 100%) - fallback to all models for reset time when primary exhausted --- ui/src/lib/utils.ts | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 55bfc105..2927ec67 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -129,46 +129,51 @@ export function getEarliestResetTime( } /** - * Filter to get Claude models from a model array + * Filter to get Claude/GPT models (primary models we care about for quota) + * These have weekly limits vs Gemini's daily limits */ -function filterClaudeModels(models: T[]): T[] { +function filterPrimaryModels(models: T[]): T[] { return models.filter((m) => { const name = (m.displayName || m.name || '').toLowerCase(); - return name.includes('claude'); + return name.includes('claude') || name.includes('gpt'); }); } /** - * Calculate the minimum quota percentage from Claude models (primary usage). - * Falls back to minimum of all models if no Claude models exist. - * Returns null if no valid models or quota data. + * Calculate the minimum quota percentage from Claude/GPT models. + * Returns 0 if Claude/GPT models are missing (exhausted/removed from API response). + * Only returns null if no models at all. */ export function getMinClaudeQuota< T extends { name: string; displayName?: string; percentage: number }, >(models: T[]): number | null { if (models.length === 0) return null; - const claudeModels = filterClaudeModels(models); - const targetModels = claudeModels.length > 0 ? claudeModels : models; - const percentages = targetModels + const primaryModels = filterPrimaryModels(models); + + // If no Claude/GPT models in response, they're exhausted (0%) + if (primaryModels.length === 0) return 0; + + const percentages = primaryModels .map((m) => m.percentage) .filter((p) => typeof p === 'number' && isFinite(p)); - if (percentages.length === 0) return null; + if (percentages.length === 0) return 0; return Math.min(...percentages); } /** - * Get reset time for Claude models (matches getMinClaudeQuota logic). - * Shows when the Claude quota will reset, not just earliest across all models. + * Get reset time for Claude/GPT models (matches getMinClaudeQuota logic). + * Falls back to earliest of all models if Claude/GPT not present (still need reset info). */ export function getClaudeResetTime< T extends { name: string; displayName?: string; resetTime: string | null }, >(models: T[]): string | null { if (models.length === 0) return null; - const claudeModels = filterClaudeModels(models); - const targetModels = claudeModels.length > 0 ? claudeModels : models; + const primaryModels = filterPrimaryModels(models); + // Fall back to all models for reset time (we need some reset info even if exhausted) + const targetModels = primaryModels.length > 0 ? primaryModels : models; return targetModels.reduce( (earliest, m) => { From ce1651714473b0d7484efbbb45ecb503d7490a3b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 16:51:49 -0500 Subject: [PATCH 13/21] fix(ui): display exhausted Claude/GPT models in quota tooltip When Claude/GPT models are exhausted (0%), API removes them from response. Added getModelsWithExhaustedIndicator() to prepend synthetic "Claude/GPT (Exhausted)" entry with 0% when primary models missing. Styled in red for visibility. Applied to both account-item and flow-viz account-card tooltips. --- .../account/flow-viz/account-card.tsx | 29 +++++++++++++++---- .../cliproxy/provider-editor/account-item.tsx | 23 +++++++++++++-- ui/src/lib/utils.ts | 21 ++++++++++++++ 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index ac86917b..51f6023f 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -8,6 +8,7 @@ import { formatResetTime, getClaudeResetTime, getMinClaudeQuota, + getModelsWithExhaustedIndicator, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react'; @@ -219,12 +220,28 @@ export function AccountCard({

Model Quotas:

- {sortModelsByPriority(quota?.models || []).map((m) => ( -
- {m.displayName || m.name} - {m.percentage}% -
- ))} + {sortModelsByPriority(getModelsWithExhaustedIndicator(quota?.models || [])).map( + (m) => ( +
+ + {m.displayName || m.name} + + + {m.percentage}% + +
+ ) + )} {(() => { const resetTime = getClaudeResetTime(quota?.models || []); return resetTime ? ( diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 8126989a..b3b478af 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -35,6 +35,7 @@ import { formatResetTime, getClaudeResetTime, getMinClaudeQuota, + getModelsWithExhaustedIndicator, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; @@ -349,10 +350,26 @@ export function AccountItem({

Model Quotas:

- {sortModelsByPriority(quota?.models || []).map((m) => ( + {sortModelsByPriority( + getModelsWithExhaustedIndicator(quota?.models || []) + ).map((m) => (
- {m.displayName || m.name} - {m.percentage}% + + {m.displayName || m.name} + + + {m.percentage}% +
))} {nextReset && ( diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 2927ec67..c9f58ad2 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -184,3 +184,24 @@ export function getClaudeResetTime< null as string | null ); } + +/** + * Augment models list with synthetic "Exhausted" entry when Claude/GPT models are missing. + * Used for tooltip display to show user that primary models are exhausted (0%). + */ +export function getModelsWithExhaustedIndicator< + T extends { name: string; displayName?: string; percentage: number }, +>(models: T[]): (T | { name: string; displayName: string; percentage: number })[] { + if (models.length === 0) return []; + + const primaryModels = filterPrimaryModels(models); + + // If primary models exist, return as-is + if (primaryModels.length > 0) return models; + + // Primary models exhausted - prepend synthetic entry + return [ + { name: 'claude-exhausted', displayName: 'Claude/GPT (Exhausted)', percentage: 0 }, + ...models, + ]; +} From de7138166ca2dca765c05ab59e9d9b1b154277bb Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 17:03:10 -0500 Subject: [PATCH 14/21] fix(ui): improve model quota tooltip tier sorting Sort models by capability tier instead of just provider: - Tier 0: Claude + GPT (primary, weekly limits) - Tier 1: Gemini 3 Pro (high capability) - Tier 2: Gemini 2.5 Pro/Flash (mid tier) - Tier 3: Other Gemini models - Tier 4: Everything else (chat_, tab_, etc.) --- ui/src/lib/utils.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index c9f58ad2..713764b2 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -56,18 +56,29 @@ export function getProviderColor(provider: string): string { } /** - * Sort models with Claude models first, then alphabetically - * Prioritizes: Claude > Gemini > GPT > Other (alphabetically) + * Sort models by tier: Primary (Claude/GPT) > Gemini 3 Pro > Gemini 2.5 > Others + * Within each tier, sorts alphabetically by display name */ export function sortModelsByPriority( models: T[] ): T[] { const getPriority = (model: T): number => { const name = (model.displayName || model.name).toLowerCase(); - if (name.includes('claude')) return 0; - if (name.includes('gemini')) return 1; - if (name.includes('gpt')) return 2; - return 3; + + // Tier 0: Primary models (Claude + GPT) - weekly limits, most valuable + if (name.includes('claude') || name.includes('gpt')) return 0; + + // Tier 1: Gemini 3 Pro models - high capability + if (name.includes('gemini 3') || name.includes('gemini-3')) return 1; + + // Tier 2: Gemini 2.5 Pro/Flash models - mid tier + if (name.includes('gemini 2.5') || name.includes('gemini-2.5')) return 2; + + // Tier 3: Other Gemini models + if (name.includes('gemini')) return 3; + + // Tier 4: Everything else + return 4; }; return [...models].sort((a, b) => { From ebfc554f5f8733b811c1ba8610a1bf11d0154510 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 17:06:06 -0500 Subject: [PATCH 15/21] feat(ui): add tiered visual grouping to quota tooltip - Replace generic "Claude/GPT (Exhausted)" with actual model names - Group models by tier with visual headers: - Claude & GPT (primary, weekly limits) - Gemini 3 (high capability) - Gemini 2.5 (mid tier) - Other - When primary models exhausted, show known models with "(Exhausted)" label - Add border separators between tier groups for visual clarity --- .../account/flow-viz/account-card.tsx | 59 +++++----- .../cliproxy/provider-editor/account-item.tsx | 59 +++++----- ui/src/lib/utils.ts | 101 ++++++++++++++++-- 3 files changed, 156 insertions(+), 63 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 51f6023f..09756a94 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -4,11 +4,13 @@ import { cn, - sortModelsByPriority, formatResetTime, getClaudeResetTime, getMinClaudeQuota, - getModelsWithExhaustedIndicator, + getModelsWithTiers, + groupModelsByTier, + getTierLabel, + type ModelTier, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react'; @@ -218,34 +220,39 @@ export function AccountCard({
-
+

Model Quotas:

- {sortModelsByPriority(getModelsWithExhaustedIndicator(quota?.models || [])).map( - (m) => ( -
- - {m.displayName || m.name} - - - {m.percentage}% - -
- ) - )} + {(() => { + const tiered = getModelsWithTiers(quota?.models || []); + const groups = groupModelsByTier(tiered); + const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; + return tierOrder.map((tier) => { + const models = groups.get(tier); + if (!models || models.length === 0) return null; + return ( +
+
+ {getTierLabel(tier)} +
+ {models.map((m) => ( +
+ + {m.displayName} + {m.exhausted && ' (Exhausted)'} + + + {m.percentage}% + +
+ ))} +
+ ); + }); + })()} {(() => { const resetTime = getClaudeResetTime(quota?.models || []); return resetTime ? ( -
+
Resets {formatResetTime(resetTime)} diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index b3b478af..ab3c56a0 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -31,11 +31,13 @@ import { } from 'lucide-react'; import { cn, - sortModelsByPriority, formatResetTime, getClaudeResetTime, getMinClaudeQuota, - getModelsWithExhaustedIndicator, + getModelsWithTiers, + groupModelsByTier, + getTierLabel, + type ModelTier, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; @@ -348,32 +350,37 @@ export function AccountItem({
-
+

Model Quotas:

- {sortModelsByPriority( - getModelsWithExhaustedIndicator(quota?.models || []) - ).map((m) => ( -
- - {m.displayName || m.name} - - - {m.percentage}% - -
- ))} + {(() => { + const tiered = getModelsWithTiers(quota?.models || []); + const groups = groupModelsByTier(tiered); + const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; + return tierOrder.map((tier) => { + const models = groups.get(tier); + if (!models || models.length === 0) return null; + return ( +
+
+ {getTierLabel(tier)} +
+ {models.map((m) => ( +
+ + {m.displayName} + {m.exhausted && ' (Exhausted)'} + + + {m.percentage}% + +
+ ))} +
+ ); + }); + })()} {nextReset && ( -
+
Resets {formatResetTime(nextReset)} diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 713764b2..6a26f103 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -196,23 +196,102 @@ export function getClaudeResetTime< ); } +// Known primary models to show when exhausted (removed from API response) +const KNOWN_PRIMARY_MODELS = [ + { name: 'claude-opus-4-5', displayName: 'Claude Opus 4.5' }, + { name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5' }, + { name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B' }, +]; + +/** Model tier for visual grouping */ +export type ModelTier = 'primary' | 'gemini-3' | 'gemini-2' | 'other'; + +/** Model with tier info for grouped display */ +export interface TieredModel { + name: string; + displayName: string; + percentage: number; + tier: ModelTier; + exhausted?: boolean; +} + +/** Get tier label for display */ +export function getTierLabel(tier: ModelTier): string { + switch (tier) { + case 'primary': + return 'Claude & GPT'; + case 'gemini-3': + return 'Gemini 3'; + case 'gemini-2': + return 'Gemini 2.5'; + case 'other': + return 'Other'; + } +} + +/** Determine tier for a model */ +function getModelTier(name: string): ModelTier { + const lower = name.toLowerCase(); + if (lower.includes('claude') || lower.includes('gpt')) return 'primary'; + if (lower.includes('gemini 3') || lower.includes('gemini-3')) return 'gemini-3'; + if (lower.includes('gemini 2') || lower.includes('gemini-2')) return 'gemini-2'; + return 'other'; +} + /** - * Augment models list with synthetic "Exhausted" entry when Claude/GPT models are missing. - * Used for tooltip display to show user that primary models are exhausted (0%). + * Convert models to tiered format with exhausted primary models injected. + * Groups models by tier for visual display in tooltip. */ -export function getModelsWithExhaustedIndicator< +export function getModelsWithTiers< T extends { name: string; displayName?: string; percentage: number }, ->(models: T[]): (T | { name: string; displayName: string; percentage: number })[] { +>(models: T[]): TieredModel[] { if (models.length === 0) return []; const primaryModels = filterPrimaryModels(models); + const result: TieredModel[] = []; - // If primary models exist, return as-is - if (primaryModels.length > 0) return models; + // If primary models exhausted, add known ones with 0% + if (primaryModels.length === 0) { + for (const known of KNOWN_PRIMARY_MODELS) { + result.push({ + name: known.name, + displayName: known.displayName, + percentage: 0, + tier: 'primary', + exhausted: true, + }); + } + } - // Primary models exhausted - prepend synthetic entry - return [ - { name: 'claude-exhausted', displayName: 'Claude/GPT (Exhausted)', percentage: 0 }, - ...models, - ]; + // Add all models with tier info + for (const m of models) { + const displayName = m.displayName || m.name; + result.push({ + name: m.name, + displayName, + percentage: m.percentage, + tier: getModelTier(displayName), + }); + } + + // Sort by tier priority, then alphabetically within tier + const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; + return result.sort((a, b) => { + const tierDiff = tierOrder.indexOf(a.tier) - tierOrder.indexOf(b.tier); + if (tierDiff !== 0) return tierDiff; + return a.displayName.localeCompare(b.displayName); + }); +} + +/** + * Group tiered models by tier for sectioned display + */ +export function groupModelsByTier(models: TieredModel[]): Map { + const groups = new Map(); + for (const m of models) { + const existing = groups.get(m.tier) || []; + existing.push(m); + groups.set(m.tier, existing); + } + return groups; } From 41d17e7cae7949dbb169963b3a0e71516afa91b3 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 17:06:44 -0500 Subject: [PATCH 16/21] fix(ui): improve tier header contrast in quota tooltip --- ui/src/components/account/flow-viz/account-card.tsx | 2 +- ui/src/components/cliproxy/provider-editor/account-item.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 09756a94..887afd05 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -231,7 +231,7 @@ export function AccountCard({ if (!models || models.length === 0) return null; return (
-
+
{getTierLabel(tier)}
{models.map((m) => ( diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index ab3c56a0..6514eb47 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -361,7 +361,7 @@ export function AccountItem({ if (!models || models.length === 0) return null; return (
-
+
{getTierLabel(tier)}
{models.map((m) => ( From b2b8a85af6c860c2e76d1f0198c2d8bc25f2ff2e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 17:09:59 -0500 Subject: [PATCH 17/21] fix(ui): simplify quota tooltip - delimiter lines only, full model names - Remove tier group headers, use delimiter lines between tiers - Show full model names (Claude Opus 4.5 (Thinking), etc.) - Show "Exhausted" in percentage column instead of appending to name - Add all known primary models when exhausted --- .../account/flow-viz/account-card.tsx | 19 +++++++++---------- .../cliproxy/provider-editor/account-item.tsx | 19 +++++++++---------- ui/src/lib/utils.ts | 5 +++-- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 887afd05..094b9038 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -9,7 +9,6 @@ import { getMinClaudeQuota, getModelsWithTiers, groupModelsByTier, - getTierLabel, type ModelTier, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -220,28 +219,28 @@ export function AccountCard({
-
+

Model Quotas:

{(() => { const tiered = getModelsWithTiers(quota?.models || []); const groups = groupModelsByTier(tiered); const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; - return tierOrder.map((tier) => { + return tierOrder.map((tier, idx) => { const models = groups.get(tier); if (!models || models.length === 0) return null; + const isFirst = tierOrder + .slice(0, idx) + .every((t) => !groups.get(t)?.length); return ( -
-
- {getTierLabel(tier)} -
+
+ {!isFirst &&
} {models.map((m) => ( -
+
{m.displayName} - {m.exhausted && ' (Exhausted)'} - {m.percentage}% + {m.exhausted ? 'Exhausted' : `${m.percentage}%`}
))} diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 6514eb47..d353e2c4 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -36,7 +36,6 @@ import { getMinClaudeQuota, getModelsWithTiers, groupModelsByTier, - getTierLabel, type ModelTier, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -350,28 +349,28 @@ export function AccountItem({
-
+

Model Quotas:

{(() => { const tiered = getModelsWithTiers(quota?.models || []); const groups = groupModelsByTier(tiered); const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; - return tierOrder.map((tier) => { + return tierOrder.map((tier, idx) => { const models = groups.get(tier); if (!models || models.length === 0) return null; + const isFirst = tierOrder + .slice(0, idx) + .every((t) => !groups.get(t)?.length); return ( -
-
- {getTierLabel(tier)} -
+
+ {!isFirst &&
} {models.map((m) => ( -
+
{m.displayName} - {m.exhausted && ' (Exhausted)'} - {m.percentage}% + {m.exhausted ? 'Exhausted' : `${m.percentage}%`}
))} diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 6a26f103..397edd5d 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -198,9 +198,10 @@ export function getClaudeResetTime< // Known primary models to show when exhausted (removed from API response) const KNOWN_PRIMARY_MODELS = [ - { name: 'claude-opus-4-5', displayName: 'Claude Opus 4.5' }, + { name: 'claude-opus-4-5-thinking', displayName: 'Claude Opus 4.5 (Thinking)' }, { name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5' }, - { name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B' }, + { name: 'claude-sonnet-4-5-thinking', displayName: 'Claude Sonnet 4.5 (Thinking)' }, + { name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B (Medium)' }, ]; /** Model tier for visual grouping */ From 27e8813cae5379dbd8f9e700155812e59fe99389 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 17:12:45 -0500 Subject: [PATCH 18/21] fix(quota): fetch quota for paused accounts + simplify exhausted display - Remove early return for paused accounts in quota fetcher - Check both auth/ and auth-paused/ directories when reading auth data - Allows users to see quota status of paused accounts to know when to unpause - Remove "Exhausted" text in tooltip, just show 0% in red (saves width) --- src/cliproxy/quota-fetcher.ts | 97 ++++++++----------- .../account/flow-viz/account-card.tsx | 2 +- .../cliproxy/provider-editor/account-item.tsx | 2 +- 3 files changed, 45 insertions(+), 56 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index df0accb9..f1b993d8 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -11,7 +11,7 @@ import { getAuthDir } from './config-generator'; import { CLIProxyProvider } from './types'; import { getProviderAccounts, - isAccountPaused, + getPausedDir, setAccountTier, type AccountInfo, type AccountTier, @@ -242,59 +242,60 @@ async function refreshAccessToken( * Read auth data from auth file (access token, project_id, expiry status) */ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | null { - const authDir = getAuthDir(); - - // Check if auth directory exists - if (!fs.existsSync(authDir)) { - return null; - } + // Check both active and paused auth directories (quota needed for paused accounts too) + const authDirs = [getAuthDir(), getPausedDir()]; // Sanitize accountId (email) to match auth file naming: @ and . → _ const sanitizedId = sanitizeEmail(accountId); const prefix = provider === 'agy' ? 'antigravity-' : `${provider}-`; const expectedFile = `${prefix}${sanitizedId}.json`; - const filePath = path.join(authDir, expectedFile); - // Direct file access (most common case) - if (fs.existsSync(filePath)) { - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const data = JSON.parse(content) as AntigravityAuthFile; - if (!data.access_token) return null; - return { - accessToken: data.access_token, - refreshToken: data.refresh_token || null, - projectId: data.project_id || null, - isExpired: isTokenExpired(data.expired), - expiresAt: data.expired || null, - }; - } catch { - return null; - } - } + for (const authDir of authDirs) { + if (!fs.existsSync(authDir)) continue; - // Fallback: scan directory for matching email in file content - const files = fs.readdirSync(authDir); - for (const file of files) { - if (file.startsWith(prefix) && file.endsWith('.json')) { - const candidatePath = path.join(authDir, file); + const filePath = path.join(authDir, expectedFile); + + // Direct file access (most common case) + if (fs.existsSync(filePath)) { try { - const content = fs.readFileSync(candidatePath, 'utf-8'); + const content = fs.readFileSync(filePath, 'utf-8'); const data = JSON.parse(content) as AntigravityAuthFile; - // Match by email field inside the auth file - if (data.email === accountId && data.access_token) { - return { - accessToken: data.access_token, - refreshToken: data.refresh_token || null, - projectId: data.project_id || null, - isExpired: isTokenExpired(data.expired), - expiresAt: data.expired || null, - }; - } + if (!data.access_token) continue; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || null, + projectId: data.project_id || null, + isExpired: isTokenExpired(data.expired), + expiresAt: data.expired || null, + }; } catch { continue; } } + + // Fallback: scan directory for matching email in file content + const files = fs.readdirSync(authDir); + for (const file of files) { + if (file.startsWith(prefix) && file.endsWith('.json')) { + const candidatePath = path.join(authDir, file); + try { + const content = fs.readFileSync(candidatePath, 'utf-8'); + const data = JSON.parse(content) as AntigravityAuthFile; + // Match by email field inside the auth file + if (data.email === accountId && data.access_token) { + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || null, + projectId: data.project_id || null, + isExpired: isTokenExpired(data.expired), + expiresAt: data.expired || null, + }; + } + } catch { + continue; + } + } + } } return null; @@ -554,19 +555,7 @@ export async function fetchAccountQuota( }; } - // Check if account is paused (token moved to auth-paused/ directory) - if (isAccountPaused(provider, accountId)) { - const error = 'Account is paused'; - if (verbose) console.error(`[i] ${error}`); - return { - success: false, - models: [], - lastUpdated: Date.now(), - error, - }; - } - - // Read auth data from auth file + // Read auth data from auth file (checks both active and paused directories) const authData = readAuthData(provider, accountId); if (!authData) { const error = 'Auth file not found for account'; diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 094b9038..6ddd4dde 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -240,7 +240,7 @@ export function AccountCard({ {m.displayName} - {m.exhausted ? 'Exhausted' : `${m.percentage}%`} + {m.percentage}%
))} diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index d353e2c4..ec71ce8a 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -370,7 +370,7 @@ export function AccountItem({ {m.displayName} - {m.exhausted ? 'Exhausted' : `${m.percentage}%`} + {m.percentage}%
))} From a84cc036df8179442498531fa0b4383f0b96d904 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 17:15:19 -0500 Subject: [PATCH 19/21] fix(ui): remove dead 'Account is paused' error handling from flow-viz Backend now returns quota for paused accounts, no longer returns this error. --- ui/src/components/account/flow-viz/account-card.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 6ddd4dde..0b957e3b 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -264,16 +264,9 @@ export function AccountCard({ ) : quota?.error ? ( - quota.error === 'Account is paused' ? ( -
- - Paused -
- ) : ( -
- {quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error} -
- ) +
+ {quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error} +
) : null}
)} From e3920e077610b3d31b7e533a629c6b09e8b6d427 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 17:34:30 -0500 Subject: [PATCH 20/21] fix(quota): return exhausted models with resetTime from API - treat models with resetTime but null/missing remainingFraction as 0% - matches CLIProxy Management Center behavior - enables accurate Claude reset time display when quota exhausted - mark primary models at 0% as exhausted for red styling --- src/cliproxy/quota-fetcher.ts | 19 +++++++++++++------ ui/src/lib/utils.ts | 14 ++++++++------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index f1b993d8..9301d985 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -494,15 +494,22 @@ async function fetchAvailableModels(accessToken: string, _projectId: string): Pr const remaining = quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining; - // Skip invalid values (NaN, Infinity, non-numbers) - if (typeof remaining !== 'number' || !isFinite(remaining)) continue; - - // Convert to percentage (0-100) and clamp to valid range - const percentage = Math.max(0, Math.min(100, Math.round(remaining * 100))); - // Extract reset time const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null; + // If remaining is not a valid number but resetTime exists, treat as exhausted (0%) + // This happens when Claude models hit quota limit - API returns resetTime but no fraction + let percentage: number; + if (typeof remaining === 'number' && isFinite(remaining)) { + percentage = Math.max(0, Math.min(100, Math.round(remaining * 100))); + } else if (resetTime) { + // Model is exhausted but has reset time - show as 0% + percentage = 0; + } else { + // No valid data, skip this model + continue; + } + models.push({ name: modelId, displayName: modelData.displayName, diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 397edd5d..a6966d0a 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -174,8 +174,8 @@ export function getMinClaudeQuota< } /** - * Get reset time for Claude/GPT models (matches getMinClaudeQuota logic). - * Falls back to earliest of all models if Claude/GPT not present (still need reset info). + * Get reset time for Claude/GPT models (primary models). + * Returns null only if no primary models present in response. */ export function getClaudeResetTime< T extends { name: string; displayName?: string; resetTime: string | null }, @@ -183,10 +183,9 @@ export function getClaudeResetTime< if (models.length === 0) return null; const primaryModels = filterPrimaryModels(models); - // Fall back to all models for reset time (we need some reset info even if exhausted) - const targetModels = primaryModels.length > 0 ? primaryModels : models; + if (primaryModels.length === 0) return null; - return targetModels.reduce( + return primaryModels.reduce( (earliest, m) => { if (!m.resetTime) return earliest; if (!earliest) return m.resetTime; @@ -267,11 +266,14 @@ export function getModelsWithTiers< // Add all models with tier info for (const m of models) { const displayName = m.displayName || m.name; + const tier = getModelTier(displayName); result.push({ name: m.name, displayName, percentage: m.percentage, - tier: getModelTier(displayName), + tier, + // Mark primary models at 0% as exhausted for red styling + exhausted: tier === 'primary' && m.percentage === 0, }); } From 7fc9ff0d77ddf89c13e8ec168b700bee38cabc12 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 17:34:55 -0500 Subject: [PATCH 21/21] feat(ui): persist provider selection in cliproxy and auth-monitor - save selected provider to localStorage on both pages - restore selection on page load - validate saved provider still exists before restoring - improves UX by eliminating need to re-select provider after reload --- .../monitoring/auth-monitor/index.tsx | 41 +++++++++++++++---- ui/src/pages/cliproxy.tsx | 29 +++++++++++-- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/ui/src/components/monitoring/auth-monitor/index.tsx b/ui/src/components/monitoring/auth-monitor/index.tsx index f6fcb8fa..93202ba3 100644 --- a/ui/src/components/monitoring/auth-monitor/index.tsx +++ b/ui/src/components/monitoring/auth-monitor/index.tsx @@ -4,7 +4,7 @@ * Uses glass panel aesthetic with hover interactions and glow effects */ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { STATUS_COLORS } from '@/lib/utils'; import { Skeleton } from '@/components/ui/skeleton'; import { AccountFlowViz } from '@/components/account-flow-viz'; @@ -17,6 +17,8 @@ import { LivePulse } from './components/live-pulse'; import { ProviderCard } from './components/provider-card'; import { SummaryCard } from './components/summary-card'; +const STORAGE_KEY = 'auth-monitor-selected-provider'; + export function AuthMonitor() { const { accounts, @@ -31,7 +33,32 @@ export function AuthMonitor() { } = useAuthMonitorData(); const { privacyMode } = usePrivacy(); - const [selectedProvider, setSelectedProvider] = useState(null); + + // Persist selected provider in localStorage + const [selectedProvider, setSelectedProviderState] = useState(() => { + if (typeof window !== 'undefined') { + return localStorage.getItem(STORAGE_KEY); + } + return null; + }); + + // Wrapper to persist selection + const setSelectedProvider = (provider: string | null) => { + setSelectedProviderState(provider); + if (provider) { + localStorage.setItem(STORAGE_KEY, provider); + } else { + localStorage.removeItem(STORAGE_KEY); + } + }; + + // Effective provider: prefer saved (if still exists) > null (show grid) + const effectiveProvider = useMemo(() => { + if (selectedProvider && providerStats.some((p) => p.provider === selectedProvider)) { + return selectedProvider; + } + return null; + }, [selectedProvider, providerStats]); const [hoveredProvider, setHoveredProvider] = useState(null); // Account control mutations for flow viz @@ -39,16 +66,16 @@ export function AuthMonitor() { const resumeMutation = useResumeAccount(); // Get selected provider data for detail view - const selectedProviderData = selectedProvider - ? providerStats.find((ps) => ps.provider === selectedProvider) + const selectedProviderData = effectiveProvider + ? providerStats.find((ps) => ps.provider === effectiveProvider) : null; const handlePauseToggle = (accountId: string, paused: boolean) => { - if (!selectedProvider || pauseMutation.isPending || resumeMutation.isPending) return; + if (!effectiveProvider || pauseMutation.isPending || resumeMutation.isPending) return; if (paused) { - pauseMutation.mutate({ provider: selectedProvider, accountId }); + pauseMutation.mutate({ provider: effectiveProvider, accountId }); } else { - resumeMutation.mutate({ provider: selectedProvider, accountId }); + resumeMutation.mutate({ provider: effectiveProvider, accountId }); } }; diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 3a1ed931..3640565f 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -194,7 +194,13 @@ export function CliproxyPage() { const deleteMutation = useDeleteVariant(); // Selection state: either a provider or a variant - const [selectedProvider, setSelectedProvider] = useState(null); + // Initialize from localStorage if available + const [selectedProvider, setSelectedProviderState] = useState(() => { + if (typeof window !== 'undefined') { + return localStorage.getItem('cliproxy-selected-provider'); + } + return null; + }); const [selectedVariant, setSelectedVariant] = useState(null); const [wizardOpen, setWizardOpen] = useState(false); const [addAccountProvider, setAddAccountProvider] = useState<{ @@ -207,14 +213,29 @@ export function CliproxyPage() { const isRemoteMode = authData?.source === 'remote'; const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); - // Auto-select first provider if nothing selected + // Wrapper to persist provider selection to localStorage + const setSelectedProvider = (provider: string | null) => { + setSelectedProviderState(provider); + if (provider) { + localStorage.setItem('cliproxy-selected-provider', provider); + } + }; + + // Effective provider: prefer saved > first with accounts > first const effectiveProvider = useMemo(() => { - // If a variant is selected, no provider is effective if (selectedVariant) return null; + + // If saved/selected provider is valid, use it if (selectedProvider && providers.some((p) => p.provider === selectedProvider)) { return selectedProvider; } - return providers.length > 0 ? providers[0].provider : null; + + // Auto-select: prefer first provider with accounts (better UX) + if (providers.length > 0) { + const providerWithAccounts = providers.find((p) => (p.accounts?.length || 0) > 0); + return providerWithAccounts?.provider || providers[0]?.provider || null; + } + return null; }, [selectedProvider, selectedVariant, providers]); const selectedStatus = providers.find((p) => p.provider === effectiveProvider);