mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user