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/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index df0accb9..9301d985 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; @@ -493,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, @@ -554,19 +562,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/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; diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 7f7d2bd7..0b957e3b 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -4,15 +4,18 @@ import { cn, - sortModelsByPriority, formatResetTime, - getEarliestResetTime, + getClaudeResetTime, getMinClaudeQuota, + getModelsWithTiers, + groupModelsByTier, + type ModelTier, } 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 +37,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 +82,8 @@ export function AccountCard({ onPointerDown, onPointerMove, onPointerUp, + onPauseToggle, + isPausingAccount, }: AccountCardProps) { const borderSide = BORDER_SIDE_MAP[zone]; const borderColor = getBorderColorStyle(zone, account.color); @@ -109,7 +116,8 @@ 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, @@ -117,7 +125,7 @@ export function AccountCard({ }} > -
+
{cleanEmail(account.email)} + {onPauseToggle && ( + + + + + + + {account.paused ? 'Resume account' : 'Pause account'} + + + + )}

Model Quotas:

- {sortModelsByPriority(quota?.models || []).map((m) => ( -
- {m.displayName || m.name} - {m.percentage}% -
- ))} {(() => { - const resetTime = getEarliestResetTime(quota?.models || []); + const tiered = getModelsWithTiers(quota?.models || []); + const groups = groupModelsByTier(tiered); + const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; + 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 ( +
+ {!isFirst &&
} + {models.map((m) => ( +
+ + {m.displayName} + + + {m.percentage}% + +
+ ))} +
+ ); + }); + })()} + {(() => { + const resetTime = getClaudeResetTime(quota?.models || []); return resetTime ? ( -
+
Resets {formatResetTime(resetTime)} @@ -202,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}
)} diff --git a/ui/src/components/account/flow-viz/index.tsx b/ui/src/components/account/flow-viz/index.tsx index 7d1dae00..bd65f53f 100644 --- a/ui/src/components/account/flow-viz/index.tsx +++ b/ui/src/components/account/flow-viz/index.tsx @@ -22,7 +22,12 @@ import { FlowVizHeader } from './flow-viz-header'; // Re-export types for backward compatibility export type { AccountData, ProviderData, AccountFlowVizProps, ConnectionEvent } from './types'; -export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { +export function AccountFlowViz({ + providerData, + onBack, + onPauseToggle, + isPausingAccount, +}: AccountFlowVizProps) { const containerRef = useRef(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 2aec131d..ec71ce8a 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -27,13 +27,16 @@ import { AlertCircle, AlertTriangle, FolderCode, + Check, } from 'lucide-react'; import { cn, - sortModelsByPriority, formatResetTime, - getEarliestResetTime, + getClaudeResetTime, getMinClaudeQuota, + getModelsWithTiers, + groupModelsByTier, + type ModelTier, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; @@ -97,6 +100,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); @@ -117,17 +123,62 @@ 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 (
+ {/* Selection checkbox for bulk actions */} + {selectable && ( + + )} + {/* Pause/Resume toggle button - visible left of avatar */} + {onPauseToggle && ( + + + + + + + {account.paused ? 'Resume account' : 'Pause account'} + + + + )}
)} - {onPauseToggle && ( - onPauseToggle(!account.paused)} - disabled={isPausingAccount} - > - {account.paused ? ( - <> - - {isPausingAccount ? 'Resuming...' : 'Resume account'} - - ) : ( - <> - - {isPausingAccount ? 'Pausing...' : 'Pause account'} - - )} - - )}

Model Quotas:

- {sortModelsByPriority(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, 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 ( +
+ {!isFirst &&
} + {models.map((m) => ( +
+ + {m.displayName} + + + {m.percentage}% + +
+ ))} +
+ ); + }); + })()} {nextReset && ( -
+
Resets {formatResetTime(nextReset)} diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx index f9d43ed8..64ed3ef5 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 && ( @@ -62,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) => ( @@ -73,10 +176,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)} /> ))}
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 { 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..59af0ac4 100644 --- a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx +++ b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx @@ -110,7 +110,7 @@ export function ProviderCard({ return (
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..93202ba3 100644 --- a/ui/src/components/monitoring/auth-monitor/index.tsx +++ b/ui/src/components/monitoring/auth-monitor/index.tsx @@ -4,11 +4,12 @@ * 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'; import { usePrivacy } from '@/contexts/privacy-context'; +import { usePauseAccount, useResumeAccount } from '@/hooks/use-cliproxy'; import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react'; import { useAuthMonitorData } from './hooks'; @@ -16,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, @@ -30,14 +33,52 @@ 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 + const pauseMutation = usePauseAccount(); + 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 (!effectiveProvider || pauseMutation.isPending || resumeMutation.isPending) return; + if (paused) { + pauseMutation.mutate({ provider: effectiveProvider, accountId }); + } else { + resumeMutation.mutate({ provider: effectiveProvider, accountId }); + } + }; + if (isLoading) { return (
@@ -121,6 +162,8 @@ export function AuthMonitor() { setSelectedProvider(null)} + onPauseToggle={handlePauseToggle} + isPausingAccount={pauseMutation.isPending || resumeMutation.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 { 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: { diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 104ebf3c..a6966d0a 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) => { @@ -81,7 +92,7 @@ export function sortModelsByPriority=24h (weekly limits) */ export function formatResetTime(resetTime: string | null): string | null { if (!resetTime) return null; @@ -92,8 +103,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 { @@ -118,25 +140,161 @@ export function getEarliestResetTime( } /** - * 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. + * Filter to get Claude/GPT models (primary models we care about for quota) + * These have weekly limits vs Gemini's daily limits + */ +function filterPrimaryModels(models: T[]): T[] { + return models.filter((m) => { + const name = (m.displayName || m.name || '').toLowerCase(); + return name.includes('claude') || name.includes('gpt'); + }); +} + +/** + * 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 = models.filter((m) => { - const name = (m.displayName || m.name || '').toLowerCase(); - return name.includes('claude'); - }); + const primaryModels = filterPrimaryModels(models); - const targetModels = claudeModels.length > 0 ? claudeModels : models; - const percentages = targetModels + // 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/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 }, +>(models: T[]): string | null { + if (models.length === 0) return null; + + const primaryModels = filterPrimaryModels(models); + if (primaryModels.length === 0) return null; + + return primaryModels.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 + ); +} + +// Known primary models to show when exhausted (removed from API response) +const KNOWN_PRIMARY_MODELS = [ + { name: 'claude-opus-4-5-thinking', displayName: 'Claude Opus 4.5 (Thinking)' }, + { name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5' }, + { 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 */ +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'; +} + +/** + * Convert models to tiered format with exhausted primary models injected. + * Groups models by tier for visual display in tooltip. + */ +export function getModelsWithTiers< + T extends { name: string; displayName?: string; percentage: number }, +>(models: T[]): TieredModel[] { + if (models.length === 0) return []; + + const primaryModels = filterPrimaryModels(models); + const result: TieredModel[] = []; + + // 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, + }); + } + } + + // 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, + // Mark primary models at 0% as exhausted for red styling + exhausted: tier === 'primary' && m.percentage === 0, + }); + } + + // 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; +} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index ec6daa57..3640565f 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,10 +188,19 @@ 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 - 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<{ @@ -201,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); @@ -232,6 +259,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 +422,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)} />