feat(ui): add expandable provider cards with account controls

- add ExpandableAccountList for inline account management

- add expand/collapse to ProviderCard with smooth transitions

- wire pause/resume/solo mutations to auth-monitor index

- add paused field to AccountRow type for UI display
This commit is contained in:
kaitranntt
2026-01-24 12:23:12 -05:00
parent 1427d36f86
commit 87226e05c4
5 changed files with 213 additions and 43 deletions
@@ -0,0 +1,83 @@
/**
* Expandable Account List - Account rows with toggle and solo controls
* Used in ProviderCard when expanded to show individual account controls
*/
import { Radio } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { cleanEmail } from '../utils';
import type { AccountRow } from '../types';
interface ExpandableAccountListProps {
accounts: AccountRow[];
privacyMode: boolean;
onPauseToggle: (accountId: string, paused: boolean) => void;
onSoloMode: (accountId: string) => void;
isPausingAccount?: boolean;
isSoloingAccount?: boolean;
}
export function ExpandableAccountList({
accounts,
privacyMode,
onPauseToggle,
onSoloMode,
isPausingAccount,
isSoloingAccount,
}: ExpandableAccountListProps) {
return (
<div className="space-y-1 pt-3 border-t border-border/50">
{accounts.map((acc) => (
<div
key={acc.id}
className={cn(
'flex items-center justify-between py-1.5 px-2 rounded',
acc.paused && 'opacity-50'
)}
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: acc.color }} />
<span className="text-xs truncate max-w-[140px]" title={acc.email}>
{privacyMode ? '••••••' : cleanEmail(acc.email)}
</span>
</div>
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
onSoloMode(acc.id);
}}
disabled={isSoloingAccount || acc.paused}
>
<Radio className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Solo mode - activate this, pause others
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
checked={!acc.paused}
onCheckedChange={(checked) => {
onPauseToggle(acc.id, !checked);
}}
disabled={isPausingAccount}
className="scale-75"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
))}
</div>
);
}
@@ -1,9 +1,11 @@
/**
* ProviderCard - Provider status card with account color dots and stats
* ProviderCard - Provider status card with expandable account controls
* Click to expand and show individual account toggle/solo buttons
*/
import type React from 'react';
import { ChevronRight, AlertTriangle } from 'lucide-react';
import { useState } from 'react';
import { ChevronRight, ChevronDown, AlertTriangle } from 'lucide-react';
import { cn, STATUS_COLORS } from '@/lib/utils';
import { PROVIDER_COLORS } from '@/lib/provider-config';
import { ProviderIcon } from '@/components/shared/provider-icon';
@@ -11,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import type { ProviderStats } from '../types';
import { getSuccessRate, cleanEmail } from '../utils';
import { InlineStatsBadge } from './inline-stats-badge';
import { ExpandableAccountList } from './expandable-account-list';
interface ProviderCardProps {
stats: ProviderStats;
@@ -19,6 +22,10 @@ interface ProviderCardProps {
onSelect: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
onPauseToggle?: (accountId: string, paused: boolean) => void;
onSoloMode?: (accountId: string) => void;
isPausingAccount?: boolean;
isSoloingAccount?: boolean;
}
export function ProviderCard({
@@ -28,15 +35,32 @@ export function ProviderCard({
onSelect,
onMouseEnter,
onMouseLeave,
onPauseToggle,
onSoloMode,
isPausingAccount,
isSoloingAccount,
}: ProviderCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const successRate = getSuccessRate(stats.successCount, stats.failureCount);
const providerColor = PROVIDER_COLORS[stats.provider.toLowerCase()] || '#6b7280';
// Only expandable if account control callbacks are provided
const isExpandable = !!(onPauseToggle && onSoloMode);
const handleClick = () => {
if (isExpandable) {
setIsExpanded(!isExpanded);
} else {
onSelect();
}
};
return (
<button
onClick={onSelect}
onClick={handleClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
aria-expanded={isExpandable ? isExpanded : undefined}
className={cn(
'group relative rounded-xl p-4 text-left transition-all duration-300',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
@@ -61,12 +85,25 @@ export function ProviderCard({
{stats.accountCount} account{stats.accountCount !== 1 ? 's' : ''}
</p>
</div>
<ChevronRight
className={cn(
'w-4 h-4 ml-auto text-muted-foreground transition-all',
isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
)}
/>
{isExpandable ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 ml-auto text-muted-foreground" />
) : (
<ChevronRight
className={cn(
'w-4 h-4 ml-auto text-muted-foreground transition-all',
isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
)}
/>
)
) : (
<ChevronRight
className={cn(
'w-4 h-4 ml-auto text-muted-foreground transition-all',
isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
)}
/>
)}
</div>
<div className="space-y-2">
@@ -104,40 +141,61 @@ export function ProviderCard({
</div>
{/* Account color dots with warning for agy accounts missing projectId */}
<div className="flex gap-1 mt-3 items-center">
{stats.accounts.slice(0, 5).map((acc) => {
const isMissingProjectId = stats.provider === 'agy' && !acc.projectId;
return (
<div key={acc.id} className="relative">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: acc.color }}
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
/>
{isMissingProjectId && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle
className="absolute -top-1 -right-1 w-2.5 h-2.5 text-amber-500"
aria-label="Missing Project ID"
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Missing Project ID - re-add account to fix
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
})}
{stats.accounts.length > 5 && (
<span className="text-[10px] text-muted-foreground ml-1">
+{stats.accounts.length - 5}
</span>
)}
</div>
{!isExpanded && (
<div className="flex gap-1 mt-3 items-center">
{stats.accounts.slice(0, 5).map((acc) => {
const isMissingProjectId = stats.provider === 'agy' && !acc.projectId;
return (
<div key={acc.id} className="relative">
<div
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
style={{ backgroundColor: acc.color }}
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
/>
{isMissingProjectId && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle
className="absolute -top-1 -right-1 w-2.5 h-2.5 text-amber-500"
aria-label="Missing Project ID"
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Missing Project ID - re-add account to fix
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
})}
{stats.accounts.length > 5 && (
<span className="text-[10px] text-muted-foreground ml-1">
+{stats.accounts.length - 5}
</span>
)}
</div>
)}
{/* Expandable account list */}
{isExpandable && (
<div
className={cn(
'overflow-hidden transition-all duration-200',
isExpanded ? 'max-h-96' : 'max-h-0'
)}
>
<ExpandableAccountList
accounts={stats.accounts}
privacyMode={privacyMode}
onPauseToggle={onPauseToggle}
onSoloMode={onSoloMode}
isPausingAccount={isPausingAccount}
isSoloingAccount={isSoloingAccount}
/>
</div>
)}
</button>
);
}
@@ -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);
@@ -9,6 +9,7 @@ import { STATUS_COLORS } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { AccountFlowViz } from '@/components/account-flow-viz';
import { usePrivacy } from '@/contexts/privacy-context';
import { usePauseAccount, useResumeAccount, useSoloAccount } from '@/hooks/use-cliproxy';
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
import { useAuthMonitorData } from './hooks';
@@ -33,11 +34,30 @@ export function AuthMonitor() {
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
// Account control mutations
const pauseMutation = usePauseAccount();
const resumeMutation = useResumeAccount();
const soloMutation = useSoloAccount();
// Get selected provider data for detail view
const selectedProviderData = selectedProvider
? providerStats.find((ps) => ps.provider === selectedProvider)
: null;
const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => {
if (pauseMutation.isPending || resumeMutation.isPending) return;
if (paused) {
pauseMutation.mutate({ provider, accountId });
} else {
resumeMutation.mutate({ provider, accountId });
}
};
const handleSoloMode = (provider: string, accountId: string) => {
if (soloMutation.isPending) return;
soloMutation.mutate({ provider, accountId });
};
if (isLoading) {
return (
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
@@ -137,6 +157,12 @@ export function AuthMonitor() {
onSelect={() => setSelectedProvider(ps.provider)}
onMouseEnter={() => setHoveredProvider(ps.provider)}
onMouseLeave={() => setHoveredProvider(null)}
onPauseToggle={(accountId, paused) =>
handlePauseToggle(ps.provider, accountId, paused)
}
onSoloMode={(accountId) => handleSoloMode(ps.provider, accountId)}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
isSoloingAccount={soloMutation.isPending}
/>
))}
</div>
@@ -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 {