Merge pull request #369 from kaitranntt/kai/fix/account-control-ux-fixes

fix(ui): account control UX improvements
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-01-25 17:37:51 -05:00
commit 251ae94620
20 files changed
+1077 -144

No files matched your search

+107
View File
@@ -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<CLIProxyProvider, Promise<unknown>>();
/**
* 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<SoloOperationResult | null> {
// 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
*/
+56 -60
View File
@@ -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';
+124 -3
View File
@@ -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<void> => {
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;
@@ -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<Zone, string> = {
@@ -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({
}}
>
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
<div className="flex justify-between items-start mb-1 mr-4">
<div className="flex items-center gap-1.5 mb-1 mr-4">
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
@@ -126,6 +134,39 @@ export function AccountCard({
>
{cleanEmail(account.email)}
</span>
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'h-4 w-4 shrink-0',
'transition-all rounded-full',
account.paused ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'hover:bg-muted'
)}
onClick={(e) => {
e.stopPropagation();
onPauseToggle(account.id, !account.paused);
}}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-2.5 h-2.5 animate-spin" />
) : account.paused ? (
<Play className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
) : (
<Pause className="w-2.5 h-2.5 text-muted-foreground/50 hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{account.paused ? 'Resume account' : 'Pause account'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<AccountCardStats
success={account.successCount}
@@ -180,16 +221,37 @@ export function AccountCard({
<TooltipContent side="top" className="max-w-xs">
<div className="text-xs space-y-1">
<p className="font-medium">Model Quotas:</p>
{sortModelsByPriority(quota?.models || []).map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className="truncate">{m.displayName || m.name}</span>
<span className="font-mono">{m.percentage}%</span>
</div>
))}
{(() => {
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 (
<div key={tier}>
{!isFirst && <div className="border-t border-border/40 my-1" />}
{models.map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
{m.displayName}
</span>
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
{m.percentage}%
</span>
</div>
))}
</div>
);
});
})()}
{(() => {
const resetTime = getClaudeResetTime(quota?.models || []);
return resetTime ? (
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-border/50">
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(resetTime)}
@@ -202,16 +264,9 @@ export function AccountCard({
</Tooltip>
</TooltipProvider>
) : quota?.error ? (
quota.error === 'Account is paused' ? (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
<Pause className="w-3 h-3" />
<span className="text-[9px] font-medium uppercase tracking-wide">Paused</span>
</div>
) : (
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
</div>
)
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
</div>
) : null}
</div>
)}
+8 -1
View File
@@ -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<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [hoveredAccount, setHoveredAccount] = useState<number | null>(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}
/>
);
});
@@ -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 {
@@ -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 (
<div
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.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30',
account.paused && 'opacity-75',
selected && 'ring-2 ring-primary/50 bg-primary/5'
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Selection checkbox for bulk actions */}
{selectable && (
<button
type="button"
onClick={() => onSelectChange?.(!selected)}
className={cn(
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
selected
? 'bg-primary border-primary text-primary-foreground'
: 'border-muted-foreground/30 hover:border-primary/50'
)}
aria-label={selected ? 'Deselect account' : 'Select account'}
>
{selected && <Check className="w-3 h-3" />}
</button>
)}
{/* Pause/Resume toggle button - visible left of avatar */}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => onPauseToggle(!account.paused)}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : account.paused ? (
<Play className="w-4 h-4 text-emerald-500" />
) : (
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{account.paused ? 'Resume account' : 'Pause account'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
@@ -239,24 +290,6 @@ export function AccountItem({
Set as default
</DropdownMenuItem>
)}
{onPauseToggle && (
<DropdownMenuItem
onClick={() => onPauseToggle(!account.paused)}
disabled={isPausingAccount}
>
{account.paused ? (
<>
<Play className="w-4 h-4 mr-2" />
{isPausingAccount ? 'Resuming...' : 'Resume account'}
</>
) : (
<>
<Pause className="w-4 h-4 mr-2" />
{isPausingAccount ? 'Pausing...' : 'Pause account'}
</>
)}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
@@ -318,14 +351,35 @@ export function AccountItem({
<TooltipContent side="bottom" className="max-w-xs">
<div className="text-xs space-y-1">
<p className="font-medium">Model Quotas:</p>
{sortModelsByPriority(quota?.models || []).map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className="truncate">{m.displayName || m.name}</span>
<span className="font-mono">{m.percentage}%</span>
</div>
))}
{(() => {
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 (
<div key={tier}>
{!isFirst && <div className="border-t border-border/40 my-1" />}
{models.map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
{m.displayName}
</span>
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
{m.percentage}%
</span>
</div>
))}
</div>
);
});
})()}
{nextReset && (
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-border/50">
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(nextReset)}
@@ -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<Set<string>>(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 (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium flex items-center gap-2">
{/* Select All checkbox */}
{isSelectable && accounts.length > 0 && (
<Checkbox
checked={allSelected}
onCheckedChange={toggleSelectAll}
aria-label="Select all accounts"
className={someSelected ? 'data-[state=checked]:bg-primary/50' : ''}
{...(someSelected ? { 'data-state': 'indeterminate' } : {})}
/>
)}
<User className="w-4 h-4" />
Accounts
{accounts.length > 0 && (
@@ -62,6 +153,18 @@ export function AccountsSection({
</Button>
</div>
{/* Bulk Action Bar - shows when accounts selected (positioned at TOP for visibility) */}
{isSelectable && selectedCount > 0 && (
<BulkActionBar
selectedCount={selectedCount}
onPauseSelected={handleBulkPause}
onResumeSelected={handleBulkResume}
onClearSelection={deselectAll}
isPausing={!!isBulkPausing}
isResuming={!!isBulkResuming}
/>
)}
{accounts.length > 0 ? (
<div className="space-y-2">
{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)}
/>
))}
</div>
@@ -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 (
<div className="flex items-center gap-3 p-2 bg-muted/50 rounded-lg border mt-2">
<span className="text-sm text-muted-foreground">{selectedCount} selected</span>
<Button
variant="ghost"
size="sm"
className="text-xs h-6 px-2"
onClick={onClearSelection}
disabled={isLoading}
>
Clear
</Button>
<div className="flex gap-2 ml-auto">
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1"
onClick={onPauseSelected}
disabled={isLoading}
>
{isPausing ? <Loader2 className="w-3 h-3 animate-spin" /> : <Pause className="w-3 h-3" />}
Pause Selected
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1"
onClick={onResumeSelected}
disabled={isLoading}
>
{isResuming ? <Loader2 className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
Resume Selected
</Button>
</div>
</div>
);
}
@@ -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}
/>
@@ -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}
@@ -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 {
@@ -110,7 +110,7 @@ export function ProviderCard({
return (
<div key={acc.id} className="relative">
<div
className="w-2 h-2 rounded-full"
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
style={{ backgroundColor: acc.color }}
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
/>
@@ -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);
@@ -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<string | null>(null);
// Persist selected provider in localStorage
const [selectedProvider, setSelectedProviderState] = useState<string | null>(() => {
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<string | null>(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 (
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
@@ -121,6 +162,8 @@ export function AuthMonitor() {
<AccountFlowViz
providerData={selectedProviderData}
onBack={() => setSelectedProvider(null)}
onPauseToggle={handlePauseToggle}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
/>
) : (
<div className="p-6">
@@ -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 {
+73
View File
@@ -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();
+24
View File
@@ -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: {
+176 -18
View File
@@ -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<T extends { name: string; displayName?: string }>(
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<T extends { name: string; displayName?: str
}
/**
* Format reset time as relative time (e.g., "in 2h 30m")
* Format reset time - relative for <24h, absolute date for >=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<T extends { resetTime: string | null }>(
}
/**
* 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<T extends { name: string; displayName?: string }>(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<ModelTier, TieredModel[]> {
const groups = new Map<ModelTier, TieredModel[]>();
for (const m of models) {
const existing = groups.get(m.tier) || [];
existing.push(m);
groups.set(m.tier, existing);
}
return groups;
}
+60 -4
View File
@@ -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<string | null>(null);
// Initialize from localStorage if available
const [selectedProvider, setSelectedProviderState] = useState<string | null>(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('cliproxy-selected-provider');
}
return null;
});
const [selectedVariant, setSelectedVariant] = useState<string | null>(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 ? (
<ProviderEditor
@@ -412,8 +462,14 @@ export function CliproxyPage() {
onPauseToggle={(accountId, paused) =>
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}
/>
) : (
<EmptyProviderState onSetup={() => setWizardOpen(true)} />