diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 2aec131d..aa19af3c 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; +import { Switch } from '@/components/ui/switch'; +import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuContent, @@ -23,10 +25,10 @@ import { CheckCircle2, HelpCircle, Pause, - Play, AlertCircle, AlertTriangle, FolderCode, + Radio, } from 'lucide-react'; import { cn, @@ -93,10 +95,15 @@ export function AccountItem({ onSetDefault, onRemove, onPauseToggle, + onSoloMode, isRemoving, isPausingAccount, + isSoloingAccount, privacyMode, showQuota, + selectable, + selected, + onSelectChange, }: AccountItemProps) { // Fetch runtime stats to get actual lastUsedAt (more accurate than file state) const { data: stats } = useCliproxyStats(showQuota); @@ -123,11 +130,20 @@ export function AccountItem({
+ {/* Multi-select checkbox */} + {selectable && ( + onSelectChange?.(!!checked)} + aria-label={`Select ${account.email || account.id}`} + /> + )}
- - - - - - {!account.isDefault && ( - - - Set as default - - )} - {onPauseToggle && ( + {/* Inline controls: Solo button + Toggle switch */} +
+ {/* Solo mode button */} + {onSoloMode && ( + + + + + + Activate only this account + + + )} + + {/* Pause/Resume toggle switch */} + {onPauseToggle && ( + + + +
+ onPauseToggle(!checked)} + disabled={isPausingAccount} + aria-label={account.paused ? 'Resume account' : 'Pause account'} + className="scale-90" + /> +
+
+ + {account.paused ? 'Resume account' : 'Pause account'} + +
+
+ )} + + {/* Dropdown menu for other actions */} + + + + + + {!account.isDefault && ( + + + Set as default + + )} onPauseToggle(!account.paused)} - disabled={isPausingAccount} + className="text-destructive focus:text-destructive" + onClick={onRemove} + disabled={isRemoving} > - {account.paused ? ( - <> - - {isPausingAccount ? 'Resuming...' : 'Resume account'} - - ) : ( - <> - - {isPausingAccount ? 'Pausing...' : 'Pause account'} - - )} + + {isRemoving ? 'Removing...' : 'Remove account'} - )} - - - {isRemoving ? 'Removing...' : 'Remove account'} - - - + + +
{/* Quota bar - supports all providers with quota API */} diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx index f9d43ed8..d69f9b58 100644 --- a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx @@ -1,13 +1,16 @@ /** * Accounts Section Component - * Manages connected OAuth accounts for a provider + * Manages connected OAuth accounts for a provider with multi-select bulk actions */ +import { useState, useMemo, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; +import { Checkbox } from '@/components/ui/checkbox'; import { User, Plus, Globe } from 'lucide-react'; import { AccountItem } from './account-item'; +import { BulkActionBar } from './bulk-action-bar'; import type { OAuthAccount } from '@/lib/api-client'; interface AccountsSectionProps { @@ -16,9 +19,21 @@ interface AccountsSectionProps { onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; onPauseToggle?: (accountId: string, paused: boolean) => void; + /** Solo mode: activate one account, pause all others */ + onSoloMode?: (accountId: string) => void; + /** Bulk pause multiple accounts */ + onBulkPause?: (accountIds: string[]) => void; + /** Bulk resume multiple accounts */ + onBulkResume?: (accountIds: string[]) => void; isRemovingAccount?: boolean; /** Pause/resume mutation in progress */ isPausingAccount?: boolean; + /** Solo mode mutation in progress */ + isSoloingAccount?: boolean; + /** Bulk pause mutation in progress */ + isBulkPausing?: boolean; + /** Bulk resume mutation in progress */ + isBulkResuming?: boolean; privacyMode?: boolean; /** Show quota bars for accounts (only applicable for 'agy' provider) */ showQuota?: boolean; @@ -35,8 +50,14 @@ export function AccountsSection({ onSetDefault, onRemoveAccount, onPauseToggle, + onSoloMode, + onBulkPause, + onBulkResume, isRemovingAccount, isPausingAccount, + isSoloingAccount, + isBulkPausing, + isBulkResuming, privacyMode, showQuota, isKiro, @@ -44,10 +65,80 @@ export function AccountsSection({ onKiroNoIncognitoChange, kiroSettingsLoading, }: AccountsSectionProps) { + // Multi-select state - raw selection (may contain stale IDs) + const [rawSelectedIds, setRawSelectedIds] = useState>(new Set()); + + // Derive valid selections by filtering out stale IDs + const accountIds = useMemo(() => new Set(accounts.map((a) => a.id)), [accounts]); + const selectedIds = useMemo( + () => new Set([...rawSelectedIds].filter((id) => accountIds.has(id))), + [rawSelectedIds, accountIds] + ); + + // Enable multi-select when bulk actions are available + const isSelectable = !!(onBulkPause && onBulkResume); + + // Computed selection state + const selectedCount = selectedIds.size; + const allSelected = accounts.length > 0 && selectedIds.size === accounts.length; + const someSelected = selectedIds.size > 0 && selectedIds.size < accounts.length; + + // Selection handlers + const toggleSelect = useCallback((id: string) => { + setRawSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const selectAll = useCallback(() => { + setRawSelectedIds(new Set(accounts.map((a) => a.id))); + }, [accounts]); + + const deselectAll = useCallback(() => { + setRawSelectedIds(new Set()); + }, []); + + const toggleSelectAll = useCallback(() => { + if (allSelected) { + deselectAll(); + } else { + selectAll(); + } + }, [allSelected, selectAll, deselectAll]); + + // Bulk action handlers + const handleBulkPause = useCallback(() => { + if (onBulkPause && selectedIds.size > 0) { + onBulkPause(Array.from(selectedIds)); + // Clear selection after action (will be invalidated by mutation success) + setRawSelectedIds(new Set()); + } + }, [onBulkPause, selectedIds]); + + const handleBulkResume = useCallback(() => { + if (onBulkResume && selectedIds.size > 0) { + onBulkResume(Array.from(selectedIds)); + setRawSelectedIds(new Set()); + } + }, [onBulkResume, selectedIds]); + return (

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

@@ -88,6 +184,18 @@ export function AccountsSection({
)} + {/* Bulk Action Bar - shows when accounts selected */} + {isSelectable && ( + + )} + {/* Kiro-specific: Incognito browser setting - users complain "it keeps opening incognito" */} {isKiro && onKiroNoIncognitoChange && (
diff --git a/ui/src/components/cliproxy/provider-editor/bulk-action-bar.tsx b/ui/src/components/cliproxy/provider-editor/bulk-action-bar.tsx new file mode 100644 index 00000000..426c8c8f --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/bulk-action-bar.tsx @@ -0,0 +1,67 @@ +/** + * Bulk Action Bar Component + * Appears when accounts are selected for bulk pause/resume operations + */ + +import { Button } from '@/components/ui/button'; +import { Pause, Play, Loader2 } from 'lucide-react'; + +interface BulkActionBarProps { + selectedCount: number; + onPauseSelected: () => void; + onResumeSelected: () => void; + onClearSelection: () => void; + isPausing: boolean; + isResuming: boolean; +} + +export function BulkActionBar({ + selectedCount, + onPauseSelected, + onResumeSelected, + onClearSelection, + isPausing, + isResuming, +}: BulkActionBarProps) { + // Show bar when at least 1 account is selected (per validation decision) + if (selectedCount < 1) return null; + + const isLoading = isPausing || isResuming; + + return ( +
+ {selectedCount} selected + +
+ + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index b2d5b089..77672594 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -39,8 +39,14 @@ export function ProviderEditor({ onSetDefault, onRemoveAccount, onPauseToggle, + onSoloMode, + onBulkPause, + onBulkResume, isRemovingAccount, isPausingAccount, + isSoloingAccount, + isBulkPausing, + isBulkResuming, }: ProviderEditorProps) { const [customPresetOpen, setCustomPresetOpen] = useState(false); const { privacyMode } = usePrivacy(); @@ -203,8 +209,14 @@ export function ProviderEditor({ onSetDefault={onSetDefault} onRemoveAccount={onRemoveAccount} onPauseToggle={onPauseToggle} + onSoloMode={onSoloMode} + onBulkPause={onBulkPause} + onBulkResume={onBulkResume} isRemovingAccount={isRemovingAccount} isPausingAccount={isPausingAccount} + isSoloingAccount={isSoloingAccount} + isBulkPausing={isBulkPausing} + isBulkResuming={isBulkResuming} privacyMode={privacyMode} isRemoteMode={isRemoteMode} /> diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx index 51e46d55..62eeadd8 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -37,9 +37,21 @@ interface ModelConfigTabProps { onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; onPauseToggle?: (accountId: string, paused: boolean) => void; + /** Solo mode: activate one account, pause all others */ + onSoloMode?: (accountId: string) => void; isRemovingAccount?: boolean; /** Pause/resume mutation in progress */ isPausingAccount?: boolean; + /** Solo mode mutation in progress */ + isSoloingAccount?: boolean; + /** Bulk pause multiple accounts */ + onBulkPause?: (accountIds: string[]) => void; + /** Bulk resume multiple accounts */ + onBulkResume?: (accountIds: string[]) => void; + /** Bulk pause mutation in progress */ + isBulkPausing?: boolean; + /** Bulk resume mutation in progress */ + isBulkResuming?: boolean; privacyMode?: boolean; /** True if connected to remote CLIProxy (quota not available) */ isRemoteMode?: boolean; @@ -64,8 +76,14 @@ export function ModelConfigTab({ onSetDefault, onRemoveAccount, onPauseToggle, + onSoloMode, + onBulkPause, + onBulkResume, isRemovingAccount, isPausingAccount, + isSoloingAccount, + isBulkPausing, + isBulkResuming, privacyMode, isRemoteMode, }: ModelConfigTabProps) { @@ -140,8 +158,14 @@ export function ModelConfigTab({ onSetDefault={onSetDefault} onRemoveAccount={onRemoveAccount} onPauseToggle={onPauseToggle} + onSoloMode={onSoloMode} + onBulkPause={onBulkPause} + onBulkResume={onBulkResume} isRemovingAccount={isRemovingAccount} isPausingAccount={isPausingAccount} + isSoloingAccount={isSoloingAccount} + isBulkPausing={isBulkPausing} + isBulkResuming={isBulkResuming} privacyMode={privacyMode} showQuota={provider === 'agy' && !isRemoteMode} isKiro={isKiro} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 743a1bf3..a9023729 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -31,9 +31,21 @@ export interface ProviderEditorProps { onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; onPauseToggle?: (accountId: string, paused: boolean) => void; + /** Solo mode: activate one account, pause all others */ + onSoloMode?: (accountId: string) => void; + /** Bulk pause multiple accounts */ + onBulkPause?: (accountIds: string[]) => void; + /** Bulk resume multiple accounts */ + onBulkResume?: (accountIds: string[]) => void; isRemovingAccount?: boolean; /** Pause/resume mutation in progress */ isPausingAccount?: boolean; + /** Solo mode mutation in progress */ + isSoloingAccount?: boolean; + /** Bulk pause mutation in progress */ + isBulkPausing?: boolean; + /** Bulk resume mutation in progress */ + isBulkResuming?: boolean; } export interface AccountItemProps { @@ -41,12 +53,22 @@ export interface AccountItemProps { onSetDefault: () => void; onRemove: () => void; onPauseToggle?: (paused: boolean) => void; + /** Solo mode: activate this account, pause all others */ + onSoloMode?: () => void; isRemoving?: boolean; /** Pause/resume mutation in progress */ isPausingAccount?: boolean; + /** Solo mode mutation in progress */ + isSoloingAccount?: boolean; privacyMode?: boolean; /** Show quota bar (only for 'agy' provider) */ showQuota?: boolean; + /** Enable checkbox for multi-select */ + selectable?: boolean; + /** Whether this account is currently selected */ + selected?: boolean; + /** Called when checkbox is toggled */ + onSelectChange?: (selected: boolean) => void; } export interface ModelMappingValues {