feat(ui): add bulk account controls to provider editor

- add BulkActionBar component for multi-select pause/resume

- add AccountItem with individual pause toggle and solo button

- fix solo button disabled logic for consistency

- add selection state and bulk operations to AccountsSection
This commit is contained in:
kaitranntt
2026-01-24 12:23:04 -05:00
parent 708661744f
commit 1427d36f86
6 changed files with 323 additions and 41 deletions
@@ -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({
<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-50 border-muted'
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Multi-select checkbox */}
{selectable && (
<Checkbox
checked={selected}
onCheckedChange={(checked) => onSelectChange?.(!!checked)}
aria-label={`Select ${account.email || account.id}`}
/>
)}
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
@@ -226,47 +242,80 @@ export function AccountItem({
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
{onPauseToggle && (
{/* Inline controls: Solo button + Toggle switch */}
<div className="flex items-center gap-1.5">
{/* Solo mode button */}
{onSoloMode && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onSoloMode}
disabled={isSoloingAccount || account.paused}
aria-label="Activate only this account"
>
{isSoloingAccount ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Radio className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Activate only this account</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Pause/Resume toggle switch */}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={!account.paused}
onCheckedChange={(checked) => onPauseToggle(!checked)}
disabled={isPausingAccount}
aria-label={account.paused ? 'Resume account' : 'Pause account'}
className="scale-90"
/>
</div>
</TooltipTrigger>
<TooltipContent>
{account.paused ? 'Resume account' : 'Pause account'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Dropdown menu for other actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => onPauseToggle(!account.paused)}
disabled={isPausingAccount}
className="text-destructive focus:text-destructive"
onClick={onRemove}
disabled={isRemoving}
>
{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'}
</>
)}
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Quota bar - supports all providers with quota API */}
@@ -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 && (
@@ -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)}
/>
))}
</div>
@@ -88,6 +184,18 @@ export function AccountsSection({
</div>
)}
{/* Bulk Action Bar - shows when accounts selected */}
{isSelectable && (
<BulkActionBar
selectedCount={selectedCount}
onPauseSelected={handleBulkPause}
onResumeSelected={handleBulkResume}
onClearSelection={deselectAll}
isPausing={!!isBulkPausing}
isResuming={!!isBulkResuming}
/>
)}
{/* Kiro-specific: Incognito browser setting - users complain "it keeps opening incognito" */}
{isKiro && onKiroNoIncognitoChange && (
<div className="mt-3 pt-3 border-t border-dashed">
@@ -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 {