-
+
- Leave empty to use the default shared group.
+ Leave empty to use the default shared group. Spaces are normalized to dashes.
+
+
+ setDeeperContinuity(checked === true)}
+ />
+
+
+
+ Adds sync for session-env, file-history,{' '}
+ shell-snapshots, and todos. Credentials stay isolated.
{contextGroup.trim().length > 0 && !isValidContextGroup && (
@@ -157,6 +175,10 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
Complete the Claude login in your browser
Return here and refresh to see the new account
+
+ Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts
+ page action button.
+
diff --git a/ui/src/components/account/edit-account-context-dialog.tsx b/ui/src/components/account/edit-account-context-dialog.tsx
new file mode 100644
index 00000000..4edf98ad
--- /dev/null
+++ b/ui/src/components/account/edit-account-context-dialog.tsx
@@ -0,0 +1,169 @@
+import { useMemo, useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import type { Account } from '@/lib/api-client';
+import { useUpdateAccountContext } from '@/hooks/use-accounts';
+
+type ContextMode = 'isolated' | 'shared';
+type ContinuityMode = 'standard' | 'deeper';
+
+const MAX_CONTEXT_GROUP_LENGTH = 64;
+const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
+
+interface EditAccountContextDialogProps {
+ account: Account;
+ onClose: () => void;
+}
+
+export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) {
+ const updateContextMutation = useUpdateAccountContext();
+ const [mode, setMode] = useState
(
+ account.context_mode === 'shared' ? 'shared' : 'isolated'
+ );
+ const [group, setGroup] = useState(account.context_group || 'default');
+ const [continuityMode, setContinuityMode] = useState(
+ account.continuity_mode === 'deeper' ? 'deeper' : 'standard'
+ );
+
+ const normalizedGroup = useMemo(() => group.trim().toLowerCase().replace(/\s+/g, '-'), [group]);
+ const isSharedGroupValid =
+ normalizedGroup.length > 0 &&
+ normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH &&
+ CONTEXT_GROUP_PATTERN.test(normalizedGroup);
+ const canSubmit = mode === 'isolated' || isSharedGroupValid;
+
+ const handleSave = () => {
+ if (!canSubmit) {
+ return;
+ }
+
+ updateContextMutation.mutate(
+ {
+ name: account.name,
+ context_mode: mode,
+ context_group: mode === 'shared' ? normalizedGroup : undefined,
+ continuity_mode: mode === 'shared' ? continuityMode : undefined,
+ },
+ {
+ onSuccess: () => {
+ onClose();
+ },
+ }
+ );
+ };
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ if (!nextOpen) {
+ onClose();
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/account/history-sync-learning-map.tsx b/ui/src/components/account/history-sync-learning-map.tsx
new file mode 100644
index 00000000..7c7b2f94
--- /dev/null
+++ b/ui/src/components/account/history-sync-learning-map.tsx
@@ -0,0 +1,168 @@
+import { useState } from 'react';
+import {
+ ArrowRight,
+ ArrowRightLeft,
+ ChevronDown,
+ Layers3,
+ Link2,
+ Unlink,
+ Waves,
+ type LucideIcon,
+} from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import { cn } from '@/lib/utils';
+
+interface HistorySyncLearningMapProps {
+ isolatedCount: number;
+ sharedStandardCount: number;
+ deeperSharedCount: number;
+ sharedGroups: string[];
+ legacyTargetCount: number;
+ cliproxyCount: number;
+}
+
+type StageTone = 'isolated' | 'shared' | 'deeper';
+
+function StageTile({
+ title,
+ count,
+ icon: Icon,
+ tone,
+}: {
+ title: string;
+ count: number;
+ icon: LucideIcon;
+ tone: StageTone;
+}) {
+ const toneClasses: Record = {
+ isolated: {
+ border: 'border-blue-300/60 bg-blue-50/40 dark:border-blue-900/40 dark:bg-blue-900/10',
+ icon: 'text-blue-700 dark:text-blue-400',
+ count: 'text-blue-700 dark:text-blue-400',
+ },
+ shared: {
+ border:
+ 'border-emerald-300/60 bg-emerald-50/40 dark:border-emerald-900/40 dark:bg-emerald-900/10',
+ icon: 'text-emerald-700 dark:text-emerald-400',
+ count: 'text-emerald-700 dark:text-emerald-400',
+ },
+ deeper: {
+ border:
+ 'border-indigo-300/60 bg-indigo-50/40 dark:border-indigo-900/40 dark:bg-indigo-900/10',
+ icon: 'text-indigo-700 dark:text-indigo-400',
+ count: 'text-indigo-700 dark:text-indigo-400',
+ },
+ };
+
+ return (
+
+ );
+}
+
+export function HistorySyncLearningMap({
+ isolatedCount,
+ sharedStandardCount,
+ deeperSharedCount,
+ sharedGroups,
+ legacyTargetCount,
+ cliproxyCount,
+}: HistorySyncLearningMapProps) {
+ const [open, setOpen] = useState(false);
+ const groupsToShow = sharedGroups.length > 0 ? sharedGroups : ['default'];
+
+ return (
+
+
+
+
+ How History Sync Works
+
+ Isolated -> Shared -> Deeper. Use Sync per row for all changes.
+
+
+
Learning Map
+
+
+
+
+ {cliproxyCount > 0 && (
+
+ {cliproxyCount} CLIProxy Claude pool account{cliproxyCount > 1 ? 's are' : ' is'}
+ managed in Action Center / CLIProxy page.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ Sync dialog lets users move between isolated/shared and choose deeper continuity.
+
+
+
+
+
+
+ Same group means shared project context lane. Default fallback is{' '}
+ default.
+
+
+ {groupsToShow.map((group) => (
+
+ {group}
+
+ ))}
+
+
+
+
+ {legacyTargetCount > 0 && (
+
+ {legacyTargetCount} legacy account
+ {legacyTargetCount > 1 ? 's still need' : ' still needs'} explicit confirmation.
+
+ )}
+
+
+
+
+ );
+}
diff --git a/ui/src/components/account/index.ts b/ui/src/components/account/index.ts
index 0b75ccb8..fe6da6ea 100644
--- a/ui/src/components/account/index.ts
+++ b/ui/src/components/account/index.ts
@@ -6,6 +6,7 @@
export { AccountsTable } from './accounts-table';
export { AddAccountDialog } from './add-account-dialog';
export { CreateAuthProfileDialog } from './create-auth-profile-dialog';
+export { EditAccountContextDialog } from './edit-account-context-dialog';
// Flow visualization (from subdirectory)
export { AccountFlowViz } from './flow-viz';
diff --git a/ui/src/hooks/use-accounts.ts b/ui/src/hooks/use-accounts.ts
index b46691af..282d1aa3 100644
--- a/ui/src/hooks/use-accounts.ts
+++ b/ui/src/hooks/use-accounts.ts
@@ -1,16 +1,63 @@
/**
- * React Query hooks for accounts (profiles.json)
+ * React Query hooks for account management
* Dashboard parity: Full CRUD operations for auth profiles
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
+import type { Account } from '@/lib/api-client';
import { toast } from 'sonner';
+export interface AuthAccountsView {
+ accounts: Account[];
+ default: string | null;
+ cliproxyCount: number;
+ legacyContextCount: number;
+ legacyContinuityCount: number;
+ sharedCount: number;
+ sharedStandardCount: number;
+ deeperSharedCount: number;
+ isolatedCount: number;
+}
+
export function useAccounts() {
return useQuery({
queryKey: ['accounts'],
queryFn: () => api.accounts.list(),
+ select: (data): AuthAccountsView => {
+ const authAccounts = data.accounts.filter((account) => account.type !== 'cliproxy');
+ const cliproxyCount = data.accounts.length - authAccounts.length;
+ const sharedCount = authAccounts.filter(
+ (account) => account.context_mode === 'shared'
+ ).length;
+ const deeperSharedCount = authAccounts.filter(
+ (account) => account.context_mode === 'shared' && account.continuity_mode === 'deeper'
+ ).length;
+ const sharedStandardCount = Math.max(sharedCount - deeperSharedCount, 0);
+ const isolatedCount = authAccounts.length - sharedCount;
+ const legacyContextCount = authAccounts.filter((account) => account.context_inferred).length;
+ const legacyContinuityCount = authAccounts.filter(
+ (account) =>
+ account.context_mode === 'shared' &&
+ account.continuity_mode !== 'deeper' &&
+ account.continuity_inferred
+ ).length;
+ const defaultAccount = authAccounts.some((account) => account.name === data.default)
+ ? data.default
+ : null;
+
+ return {
+ accounts: authAccounts,
+ default: defaultAccount,
+ cliproxyCount,
+ legacyContextCount,
+ legacyContinuityCount,
+ sharedCount,
+ sharedStandardCount,
+ deeperSharedCount,
+ isolatedCount,
+ };
+ },
});
}
@@ -58,3 +105,75 @@ export function useDeleteAccount() {
},
});
}
+
+export function useUpdateAccountContext() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({
+ name,
+ context_mode,
+ context_group,
+ continuity_mode,
+ }: {
+ name: string;
+ context_mode: 'isolated' | 'shared';
+ context_group?: string;
+ continuity_mode?: 'standard' | 'deeper';
+ }) => api.accounts.updateContext(name, { context_mode, context_group, continuity_mode }),
+ onSuccess: (_data, vars) => {
+ queryClient.invalidateQueries({ queryKey: ['accounts'] });
+ const contextSummary =
+ vars.context_mode === 'shared'
+ ? vars.continuity_mode === 'deeper'
+ ? `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, deeper continuity)`
+ : `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, standard)`
+ : 'isolated';
+ toast.success(`Updated "${vars.name}" context to ${contextSummary}`);
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ });
+}
+
+export function useConfirmLegacyAccountPolicies() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (accounts: Account[]) => {
+ const legacyTargets = accounts.filter(
+ (account) => account.context_inferred || account.continuity_inferred
+ );
+
+ for (const account of legacyTargets) {
+ const isShared = account.context_mode === 'shared';
+ await api.accounts.updateContext(account.name, {
+ context_mode: isShared ? 'shared' : 'isolated',
+ context_group: isShared ? account.context_group || 'default' : undefined,
+ continuity_mode: isShared
+ ? account.continuity_mode === 'deeper'
+ ? 'deeper'
+ : 'standard'
+ : undefined,
+ });
+ }
+
+ return { updatedCount: legacyTargets.length };
+ },
+ onSuccess: ({ updatedCount }) => {
+ queryClient.invalidateQueries({ queryKey: ['accounts'] });
+ if (updatedCount > 0) {
+ toast.success(
+ `Confirmed explicit sync mode for ${updatedCount} legacy account${updatedCount > 1 ? 's' : ''}`
+ );
+ return;
+ }
+
+ toast.info('No legacy accounts need confirmation');
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ });
+}
diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts
index 12ec590e..5f581f2f 100644
--- a/ui/src/lib/api-client.ts
+++ b/ui/src/lib/api-client.ts
@@ -455,6 +455,17 @@ export interface Account {
last_used?: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
+ continuity_mode?: 'standard' | 'deeper';
+ context_inferred?: boolean;
+ continuity_inferred?: boolean;
+ provider?: string;
+ displayName?: string;
+}
+
+export interface UpdateAccountContext {
+ context_mode: 'isolated' | 'shared';
+ context_group?: string;
+ continuity_mode?: 'standard' | 'deeper';
}
// Unified config types
@@ -785,6 +796,11 @@ export const api = {
}),
resetDefault: () => request('/accounts/reset-default', { method: 'DELETE' }),
delete: (name: string) => request(`/accounts/${name}`, { method: 'DELETE' }),
+ updateContext: (name: string, data: UpdateAccountContext) =>
+ request(`/accounts/${encodeURIComponent(name)}/context`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
},
// Unified config API
config: {
diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx
index bbed9c89..b0c5efe1 100644
--- a/ui/src/pages/accounts.tsx
+++ b/ui/src/pages/accounts.tsx
@@ -4,42 +4,321 @@
*/
import { useState } from 'react';
-import { Plus } from 'lucide-react';
+import { useNavigate } from 'react-router-dom';
+import { AlertTriangle, ArrowRight, ChevronDown, Plus, Users, Zap } from 'lucide-react';
import { AccountsTable } from '@/components/account/accounts-table';
import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog';
+import { HistorySyncLearningMap } from '@/components/account/history-sync-learning-map';
+import { CopyButton } from '@/components/ui/copy-button';
import { Button } from '@/components/ui/button';
-import { useAccounts } from '@/hooks/use-accounts';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { useAccounts, useConfirmLegacyAccountPolicies } from '@/hooks/use-accounts';
+import { cn } from '@/lib/utils';
export function AccountsPage() {
- const { data, isLoading, refetch } = useAccounts();
+ const navigate = useNavigate();
+ const { data, isLoading } = useAccounts();
+ const confirmLegacyMutation = useConfirmLegacyAccountPolicies();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
+ const [guideOpen, setGuideOpen] = useState(false);
+
+ const authAccounts = data?.accounts || [];
+ const cliproxyCount = data?.cliproxyCount || 0;
+ const legacyContextCount = data?.legacyContextCount || 0;
+ const legacyContinuityCount = data?.legacyContinuityCount || 0;
+ const sharedCount = data?.sharedCount || 0;
+ const sharedStandardCount = data?.sharedStandardCount || 0;
+ const deeperSharedCount = data?.deeperSharedCount || 0;
+ const isolatedCount = data?.isolatedCount || 0;
+ const sharedGroups = Array.from(
+ new Set(
+ authAccounts
+ .filter((account) => account.context_mode === 'shared')
+ .map((account) => account.context_group || 'default')
+ )
+ ).sort((a, b) => a.localeCompare(b));
+
+ const legacyTargets = authAccounts.filter(
+ (account) => account.context_inferred || account.continuity_inferred
+ );
+ const legacyTargetCount = legacyTargets.length;
+ const hasLegacyFollowUp = legacyTargetCount > 0;
+
+ const handleOpenClaudePool = () => navigate('/cliproxy?provider=claude');
+ const handleOpenClaudePoolAuth = () => navigate('/cliproxy?provider=claude&action=auth');
+ const handleConfirmLegacy = () => confirmLegacyMutation.mutate(legacyTargets);
return (
-
-
-
-
Accounts
-
- Manage multi-account Claude sessions (profiles.json)
-
+ <>
+
+ {/* Left action column */}
+
+
+
+
+
Accounts
+
+
+ Manage
+ ccs auth
+ accounts and pool onboarding from one panel.
+
+
+
+
+
+
+
+ Primary Actions
+
+
+
+
+
+
+ {hasLegacyFollowUp ? (
+
+
+ Migration Follow-up
+
+
+
+
+
+ {legacyContextCount > 0 && (
+
+ {legacyContextCount} account
+ {legacyContextCount > 1 ? 's still need' : ' still needs'} first-time
+ mode confirmation.
+
+ )}
+ {legacyContinuityCount > 0 && (
+
+ {legacyContinuityCount} shared account
+ {legacyContinuityCount > 1 ? 's remain' : ' remains'} on standard legacy
+ continuity depth.
+
+ )}
+
+
+
+
+
+ ) : (
+
+ No legacy follow-up pending.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
Shared Standard
+
+ Project workspace sync only. Best default for most teams.
+
+
+
+
Shared Deeper
+
+ Adds session-env, file-history,{' '}
+ shell-snapshots, todos.
+
+
+
+
Isolated
+
No link. Best for strict separation.
+
+
+
+
+
+
+
+
+ Quick Commands
+ Copy and run in terminal.
+
+
+
+
+ ccs auth create work --context-group sprint-a --deeper-continuity
+
+
+
+
+ ccs cliproxy auth claude
+
+
+
+
+
+
+
+
+ {/* Main workspace */}
+
+
+
+ ccs auth Workspace
+ History Sync Controls
+
+
Auth Accounts
+
+ This table is intentionally scoped to
+ ccs auth
+ accounts. Use
+ Sync
+ for mode/group/depth changes.
+
+
+
+
+
+
+
+
+ Account Matrix
+
+ Shared total: {sharedCount}. Actions include Sync settings and legacy
+ confirmation.
+
+
+
+ {isLoading ? (
+ Loading accounts...
+ ) : (
+
+ )}
+
+
+
-
- {isLoading ? (
-
Loading accounts...
- ) : (
-
+
+
+ Accounts
+
+ Manage
+ ccs auth
+ continuity per account.
+
+
+
+
+
+
+
+
+
+
+
- )}
+
+
+
+ Account Matrix
+
+
+ {isLoading ? (
+ Loading accounts...
+ ) : (
+
+ )}
+
+
+
setCreateDialogOpen(false)} />
-
+ >
);
}
diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx
index 5f6229b7..345f0fd0 100644
--- a/ui/src/pages/cliproxy.tsx
+++ b/ui/src/pages/cliproxy.tsx
@@ -4,7 +4,7 @@
* Right panel: Provider Editor with split-view (settings + code editor)
*/
-import { useState, useMemo } from 'react';
+import { useMemo, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -32,6 +32,7 @@ import {
} from '@/hooks/use-cliproxy';
import type { AuthStatus, Variant } from '@/lib/api-client';
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
+import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config';
import { cn } from '@/lib/utils';
// Sidebar provider item
@@ -198,9 +199,14 @@ export function CliproxyPage() {
const deleteMutation = useDeleteVariant();
// Selection state: either a provider or a variant
- // Initialize from localStorage if available
+ // Initialize from URL provider deep-link, fallback to localStorage.
const [selectedProvider, setSelectedProviderState] = useState
(() => {
if (typeof window !== 'undefined') {
+ const query = new URLSearchParams(window.location.search);
+ const queryProvider = query.get('provider')?.trim().toLowerCase();
+ if (queryProvider && isValidProvider(queryProvider)) {
+ return queryProvider;
+ }
return localStorage.getItem('cliproxy-selected-provider');
}
return null;
@@ -211,7 +217,25 @@ export function CliproxyPage() {
provider: string;
displayName: string;
isFirstAccount: boolean;
- } | null>(null);
+ } | null>(() => {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ const query = new URLSearchParams(window.location.search);
+ const queryProvider = query.get('provider')?.trim().toLowerCase();
+ const action = query.get('action');
+
+ if (action !== 'auth' || !queryProvider || !isValidProvider(queryProvider)) {
+ return null;
+ }
+
+ return {
+ provider: queryProvider,
+ displayName: getProviderDisplayName(queryProvider),
+ isFirstAccount: false,
+ };
+ });
const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]);
const isRemoteMode = authData?.source === 'remote';