diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx
index c707e212..daa07b65 100644
--- a/ui/src/components/account-flow-viz.tsx
+++ b/ui/src/components/account-flow-viz.tsx
@@ -1,1144 +1,12 @@
/**
* Account Flow Visualization
- * Custom SVG bezier curve visualization showing request flow from accounts to providers
- * Inspired by modern dark theme design with glass panels and glow effects
+ * Re-exports from modularized location for backward compatibility
*/
-import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
-import { cn } from '@/lib/utils';
-import { ProviderIcon } from '@/components/provider-icon';
-import { PROVIDER_COLORS } from '@/lib/provider-config';
-import { STATUS_COLORS } from '@/lib/utils';
-import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
-import {
- ChevronRight,
- CheckCircle2,
- XCircle,
- Activity,
- GripVertical,
- Eye,
- EyeOff,
- RotateCcw,
-} from 'lucide-react';
-
-// Maximum events to display in the Connection Timeline to prevent performance issues
-const MAX_TIMELINE_EVENTS = 100;
-
-// Earthy, sophisticated color palette for connection lines - works in both light/dark themes
-const CONNECTION_COLORS = [
- '#3b3c36', // Charcoal Brown - urban mystery
- '#568203', // Forest Moss - woodland depth
- '#8d4557', // Vintage Berry - timeless elegance
- '#da9100', // Harvest Gold - sun-drenched warmth
- '#3c6c82', // Blue Slate - cool authority
- '#c96907', // Burnt Caramel - earthy comfort
-];
-
-/** Get a muted connection color based on index */
-function getConnectionColor(index: number): string {
- return CONNECTION_COLORS[index % CONNECTION_COLORS.length];
-}
-
-/** Position offset for draggable cards */
-interface DragOffset {
- x: number;
- y: number;
-}
-
-interface AccountData {
- id: string;
- email: string;
- provider: string;
- successCount: number;
- failureCount: number;
- lastUsedAt?: string;
- color: string;
-}
-
-interface ProviderData {
- provider: string;
- displayName: string;
- totalRequests: number;
- accounts: AccountData[];
-}
-
-interface AccountFlowVizProps {
- providerData: ProviderData;
- onBack?: () => void;
-}
-
-interface ConnectionEvent {
- id: string;
- timestamp: Date;
- accountEmail: string;
- status: 'success' | 'failed' | 'pending';
- latencyMs?: number;
-}
-
-/** Generate connection events from real account data */
-function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] {
- const events: ConnectionEvent[] = [];
-
- accounts.forEach((account) => {
- const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date();
-
- // Helper to add events
- const addEvents = (count: number, status: 'success' | 'failed') => {
- for (let i = 0; i < count; i++) {
- // Simulate timestamps:
- // - Distribute events over a 24-hour window relative to lastUsed
- // - Add random jitter so events from different accounts mix
- const timeOffset = Math.floor(Math.random() * 24 * 60 * 60 * 1000 * (i / (count || 1)));
- const timestamp = new Date(lastUsed.getTime() - timeOffset);
-
- // Add small random jitter (±5 mins) to avoid exact overlaps
- const jitter = Math.floor((Math.random() - 0.5) * 10 * 60 * 1000);
- timestamp.setTime(timestamp.getTime() + jitter);
-
- // Sanity check: don't go into the future relative to "now"
- const now = new Date();
- if (timestamp > now) timestamp.setTime(now.getTime());
-
- events.push({
- id: `${account.id}-${status}-${i}`,
- timestamp,
- accountEmail: account.email,
- status,
- // Simulate realistic latency (success: 50-200ms, failed: 200-5000ms)
- latencyMs:
- status === 'success'
- ? 50 + Math.floor(Math.random() * 150)
- : 200 + Math.floor(Math.random() * 4800),
- });
- }
- };
-
- addEvents(account.successCount, 'success');
- addEvents(account.failureCount, 'failed');
- });
-
- // Sort by timestamp descending (most recent first)
- return events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
-}
-
-/** Format timestamp for timeline display */
-function formatTimelineTime(date: Date): string {
- const now = new Date();
- const diffMs = now.getTime() - date.getTime();
- const diffMins = Math.floor(diffMs / 60000);
-
- if (diffMins < 1) return 'now';
- if (diffMins < 60) return `${diffMins}m`;
- const diffHours = Math.floor(diffMins / 60);
- if (diffHours < 24) return `${diffHours}h`;
- return `${Math.floor(diffHours / 24)}d`;
-}
-
-/** Connection Timeline Component - right sidebar panel */
-function ConnectionTimeline({
- events,
- privacyMode,
-}: {
- events: ConnectionEvent[];
- privacyMode: boolean;
-}) {
- if (events.length === 0) {
- return (
-
-
No recent connections
-
- );
- }
-
- return (
-
- {/* Header */}
-
-
-
- Connection Timeline
-
-
-
- {/* Timeline container */}
-
-
- {/* Vertical line */}
-
-
- {/* Events */}
-
- {events.map((event) => {
- const statusColor =
- event.status === 'success'
- ? STATUS_COLORS.success
- : event.status === 'failed'
- ? STATUS_COLORS.failed
- : STATUS_COLORS.degraded;
-
- return (
-
- {/* Timeline dot */}
-
-
- {/* Event content */}
-
-
-
- {cleanEmail(event.accountEmail)}
-
-
- {formatTimelineTime(event.timestamp)}
-
-
-
-
- {event.status}
-
- {event.latencyMs && (
-
- {event.latencyMs}ms
-
- )}
-
-
-
- );
- })}
-
-
-
-
- );
-}
-
-/** Strip common email domains for cleaner display */
-function cleanEmail(email: string): string {
- return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, '');
-}
-
-/** Premium compact stats visualization for account cards */
-function AccountCardStats({
- success,
- failure,
- showDetails,
-}: {
- success: number;
- failure: number;
- showDetails: boolean;
-}) {
- const total = success + failure;
- const successRate = total > 0 ? (success / total) * 100 : 100;
-
- return (
-
- {/* Primary Row: Success Rate & Total */}
-
-
-
- Success Rate
-
- = 90
- ? 'text-amber-500'
- : 'text-red-500'
- )}
- >
- {Math.round(successRate)}%
-
-
-
-
- Volume
-
-
- {total.toLocaleString()}
-
-
-
-
- {/* Detailed Stats - Collapsible */}
-
-
-
-
- {success}
-
-
-
0
- ? 'bg-red-500/5 dark:bg-red-500/10 border-red-500/20'
- : 'bg-muted/10 border-transparent opacity-40'
- )}
- >
- 0 ? 'text-red-500' : 'text-muted-foreground')}
- />
- 0 ? 'text-red-500' : 'text-muted-foreground'
- )}
- >
- {failure}
-
-
-
-
- );
-}
-
-export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
- const containerRef = useRef(null);
- const svgRef = useRef(null);
- const [hoveredAccount, setHoveredAccount] = useState(null);
- const [showDetails, setShowDetails] = useState(false);
- const [paths, setPaths] = useState([]);
-
- // Privacy mode for demo purposes
- const { privacyMode } = usePrivacy();
-
- // Drag state for all cards (account IDs + 'provider')
- const [draggingId, setDraggingId] = useState(null);
- const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
- null
- );
- const didDragRef = useRef(false); // Track if actual movement occurred (for click vs drag detection)
-
- // LocalStorage persistence for card positions
- const storageKey = `ccs-flow-positions-${providerData.provider}`;
- const loadSavedPositions = useCallback((): Record => {
- try {
- const saved = localStorage.getItem(storageKey);
- if (saved) return JSON.parse(saved);
- } catch {
- // Ignore parse errors
- }
- return {};
- }, [storageKey]);
-
- const [dragOffsets, setDragOffsets] = useState>(() =>
- loadSavedPositions()
- );
-
- // Save positions to localStorage when they change
- useEffect(() => {
- if (Object.keys(dragOffsets).length > 0) {
- localStorage.setItem(storageKey, JSON.stringify(dragOffsets));
- }
- }, [dragOffsets, storageKey]);
-
- // Reset positions handler
- const resetPositions = useCallback(() => {
- setDragOffsets({});
- localStorage.removeItem(storageKey);
- }, [storageKey]);
-
- // Pulse state: account IDs that are currently pulsing
- const [pulsingAccounts, setPulsingAccounts] = useState>(new Set());
- // Store previous counts to detect changes
- const [prevCounts, setPrevCounts] = useState>({});
-
- const { accounts } = providerData;
- const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1);
- const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0);
-
- // Detect new activity and trigger pulse (runs when accounts data changes)
- useEffect(() => {
- const newPulsing = new Set();
- const newCounts: Record = {};
-
- accounts.forEach((account) => {
- const currentCount = account.successCount + account.failureCount;
- newCounts[account.id] = currentCount;
- const prev = prevCounts[account.id] ?? 0;
-
- if (currentCount > prev && prev > 0) {
- newPulsing.add(account.id);
- }
- });
-
- setPrevCounts(newCounts);
-
- if (newPulsing.size > 0) {
- setPulsingAccounts(newPulsing);
- // Clear pulse after animation completes (match CSS animation duration)
- const timer = setTimeout(() => setPulsingAccounts(new Set()), 2000);
- return () => clearTimeout(timer);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [accounts]);
-
- // Generate connection events for timeline
- // Limit events to prevent UI lag with high request counts
- const connectionEvents = useMemo(
- () => generateConnectionEvents(accounts).slice(0, MAX_TIMELINE_EVENTS),
- [accounts]
- );
-
- // Calculate SVG paths for bezier curves
- const calculatePaths = useCallback(() => {
- if (!containerRef.current || !svgRef.current) return;
-
- const container = containerRef.current;
- const svg = svgRef.current;
- const svgRect = svg.getBoundingClientRect();
-
- const destEl = container.querySelector('[data-provider-node]');
- if (!destEl) return;
- const destRect = destEl.getBoundingClientRect();
-
- const newPaths: string[] = [];
-
- accounts.forEach((_, i) => {
- const sourceEl = container.querySelector(`[data-account-index="${i}"]`);
- if (!sourceEl) return;
- const sourceRect = sourceEl.getBoundingClientRect();
-
- // Determine zone from data attribute
- const zone = sourceEl.getAttribute('data-zone') || 'left';
-
- let startX: number, startY: number, destX: number, destY: number;
-
- // Note: getBoundingClientRect already includes CSS transforms, so offset is implicit
-
- switch (zone) {
- case 'right':
- // Right side: connect from left edge of card to right edge of provider
- startX = sourceRect.left - svgRect.left;
- startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
- destX = destRect.right - svgRect.left;
- destY = destRect.top + destRect.height / 2 - svgRect.top;
- break;
- case 'top':
- // Top side: connect from bottom edge of card to top edge of provider
- startX = sourceRect.left + sourceRect.width / 2 - svgRect.left;
- startY = sourceRect.bottom - svgRect.top;
- destX = destRect.left + destRect.width / 2 - svgRect.left;
- destY = destRect.top - svgRect.top;
- break;
- case 'bottom':
- // Bottom side: connect from top edge of card to bottom edge of provider
- startX = sourceRect.left + sourceRect.width / 2 - svgRect.left;
- startY = sourceRect.top - svgRect.top;
- destX = destRect.left + destRect.width / 2 - svgRect.left;
- destY = destRect.bottom - svgRect.top;
- break;
- default: // 'left'
- // Left side: connect from right edge of card to left edge of provider
- startX = sourceRect.right - svgRect.left;
- startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
- destX = destRect.left - svgRect.left;
- destY = destRect.top + destRect.height / 2 - svgRect.top;
- }
-
- // Bezier control points - adjust based on zone direction
- // Note: getBoundingClientRect already includes CSS transforms, so no manual offset needed
- let cp1X: number, cp1Y: number, cp2X: number, cp2Y: number;
-
- if (zone === 'top' || zone === 'bottom') {
- // Vertical connection - control points extend horizontally for curve
- cp1X = startX;
- cp1Y = startY + (destY - startY) * 0.5;
- cp2X = destX;
- cp2Y = destY - (destY - startY) * 0.5;
- } else {
- // Horizontal connection - control points extend vertically for curve
- cp1X = startX + (destX - startX) * 0.5;
- cp1Y = startY;
- cp2X = destX - (destX - startX) * 0.5;
- cp2Y = destY;
- }
-
- newPaths.push(`M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${destX} ${destY}`);
- });
-
- setPaths(newPaths);
- }, [accounts]);
-
- useEffect(() => {
- // Initial calculation after render
- const timer = setTimeout(calculatePaths, 50);
- window.addEventListener('resize', calculatePaths);
- return () => {
- clearTimeout(timer);
- window.removeEventListener('resize', calculatePaths);
- };
- }, [calculatePaths]);
-
- // Recalculate paths when drag offsets change (including reset)
- useEffect(() => {
- const timer = setTimeout(calculatePaths, 10);
- return () => clearTimeout(timer);
- }, [dragOffsets, calculatePaths]);
-
- // Animate paths when toggling details to match CSS transition
- useEffect(() => {
- const startTime = Date.now();
- const duration = 350; // Match transition duration (300ms) + buffer
-
- const animate = () => {
- calculatePaths();
- if (Date.now() - startTime < duration) {
- requestAnimationFrame(animate);
- }
- };
-
- requestAnimationFrame(animate);
- }, [showDetails, calculatePaths]);
-
- const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280';
-
- // Split accounts into zones based on count (top/left/right/bottom)
- const { leftAccounts, rightAccounts, topAccounts, bottomAccounts } = useMemo(() => {
- const count = accounts.length;
- // 1-2 accounts: left only
- if (count <= 2) {
- return { leftAccounts: accounts, rightAccounts: [], topAccounts: [], bottomAccounts: [] };
- }
- // 3-4 accounts: left and right
- if (count <= 4) {
- const mid = Math.ceil(count / 2);
- return {
- leftAccounts: accounts.slice(0, mid),
- rightAccounts: accounts.slice(mid),
- topAccounts: [],
- bottomAccounts: [],
- };
- }
- // 5-8 accounts: left, right, top
- if (count <= 8) {
- const perZone = Math.ceil(count / 3);
- return {
- leftAccounts: accounts.slice(0, perZone),
- rightAccounts: accounts.slice(perZone, perZone * 2),
- topAccounts: accounts.slice(perZone * 2),
- bottomAccounts: [],
- };
- }
- // 9+ accounts: all four zones
- const perZone = Math.ceil(count / 4);
- return {
- leftAccounts: accounts.slice(0, perZone),
- rightAccounts: accounts.slice(perZone, perZone * 2),
- topAccounts: accounts.slice(perZone * 2, perZone * 3),
- bottomAccounts: accounts.slice(perZone * 3),
- };
- }, [accounts]);
-
- const hasRightAccounts = rightAccounts.length > 0;
- const hasTopAccounts = topAccounts.length > 0;
- const hasBottomAccounts = bottomAccounts.length > 0;
-
- // Container expansion based on drag offsets (only vertical to avoid squeezing horizontal content)
- const containerExpansion = useMemo(() => {
- let minY = 0,
- maxY = 0;
- Object.values(dragOffsets).forEach((offset) => {
- minY = Math.min(minY, offset.y);
- maxY = Math.max(maxY, offset.y);
- });
- return {
- paddingTop: Math.max(0, -minY),
- paddingBottom: Math.max(0, maxY),
- extraHeight: Math.max(0, Math.abs(minY), Math.abs(maxY)) * 2,
- };
- }, [dragOffsets]);
-
- // Dynamic provider card size based on account count
- const providerSize = useMemo(() => {
- const count = accounts.length;
- if (count >= 9) return 'w-64'; // 4 zones - largest
- if (count >= 5) return 'w-60'; // 3 zones
- if (count >= 3) return 'w-56'; // 2 zones
- return 'w-52'; // 1 zone - default
- }, [accounts.length]);
-
- // Drag handlers
- const handlePointerDown = useCallback(
- (id: string, e: React.PointerEvent) => {
- e.preventDefault();
- e.stopPropagation();
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
- const offset = dragOffsets[id] || { x: 0, y: 0 };
- dragStartRef.current = { x: e.clientX, y: e.clientY, offsetX: offset.x, offsetY: offset.y };
- didDragRef.current = false; // Reset movement flag
- setDraggingId(id);
- },
- [dragOffsets]
- );
-
- const handlePointerMove = useCallback(
- (e: React.PointerEvent) => {
- if (!draggingId || !dragStartRef.current) return;
- const start = dragStartRef.current;
- const dx = e.clientX - start.x;
- const dy = e.clientY - start.y;
- // Track if actual movement occurred (threshold of 3px)
- if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
- didDragRef.current = true;
- }
- setDragOffsets((prev) => ({
- ...prev,
- [draggingId]: {
- x: start.offsetX + dx,
- y: start.offsetY + dy,
- },
- }));
- // Recalculate paths during drag
- requestAnimationFrame(calculatePaths);
- },
- [draggingId, calculatePaths]
- );
-
- const handlePointerUp = useCallback(() => {
- setDraggingId(null);
- dragStartRef.current = null;
- }, []);
-
- // Get offset for a card
- const getOffset = (id: string): DragOffset => dragOffsets[id] || { x: 0, y: 0 };
-
- return (
-
- {/* Header: Back button + Reset Layout */}
-
- {onBack ? (
-
- ) : (
-
- )}
-
-
-
- {Object.keys(dragOffsets).length > 0 && (
-
- )}
-
-
-
- {/* Main visualization area - Multi-zone layout */}
-
- {/* Flow visualization section */}
-
- {/* SVG Canvas (Background) */}
-
-
- {/* Top Zone Accounts */}
- {hasTopAccounts && (
-
- {topAccounts.map((account) => {
- const originalIndex = accounts.findIndex((a) => a.id === account.id);
- const isHovered = hoveredAccount === originalIndex;
- const isDragging = draggingId === account.id;
- const offset = getOffset(account.id);
-
- return (
-
setHoveredAccount(originalIndex)}
- onMouseLeave={() => setHoveredAccount(null)}
- onPointerDown={(e) => handlePointerDown(account.id, e)}
- onPointerMove={handlePointerMove}
- onPointerUp={handlePointerUp}
- onPointerCancel={handlePointerUp}
- className={cn(
- 'group/card relative rounded-lg p-3 pb-4 w-44 cursor-grab transition-shadow duration-200',
- 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
- 'border border-border/50 dark:border-white/[0.08]',
- 'border-t-2 select-none touch-none',
- isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
- isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
- )}
- style={{
- borderTopColor: account.color,
- transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
- }}
- >
-
-
-
- {cleanEmail(account.email)}
-
-
-
- {/* Connector Dot - Bottom side */}
-
-
- );
- })}
-
- )}
-
- {/* Middle Row: Left | Center Provider | Right */}
-
- {/* Left Accounts */}
-
- {leftAccounts.map((account) => {
- const originalIndex = accounts.findIndex((a) => a.id === account.id);
- const isHovered = hoveredAccount === originalIndex;
- const isDragging = draggingId === account.id;
- const offset = getOffset(account.id);
-
- return (
-
setHoveredAccount(originalIndex)}
- onMouseLeave={() => setHoveredAccount(null)}
- onPointerDown={(e) => handlePointerDown(account.id, e)}
- onPointerMove={handlePointerMove}
- onPointerUp={handlePointerUp}
- onPointerCancel={handlePointerUp}
- className={cn(
- 'group/card relative rounded-lg p-3 pr-4 w-44 cursor-grab transition-shadow duration-200',
- 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
- 'border border-border/50 dark:border-white/[0.08]',
- 'border-l-2 select-none touch-none',
- isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
- isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
- )}
- style={{
- borderLeftColor: account.color,
- transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
- }}
- >
- {/* Drag handle indicator */}
-
-
-
- {cleanEmail(account.email)}
-
-
-
- {/* Connector Dot - Right side */}
-
-
- );
- })}
-
-
- {/* Center Provider */}
-
- {(() => {
- const isDragging = draggingId === 'provider';
- const offset = getOffset('provider');
- return (
-
handlePointerDown('provider', e)}
- onPointerMove={handlePointerMove}
- onPointerUp={handlePointerUp}
- onPointerCancel={handlePointerUp}
- className={cn(
- 'group relative w-full rounded-xl p-4 cursor-grab transition-shadow duration-200',
- 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
- 'border-2 border-border/50 dark:border-white/[0.08]',
- // Idle animations: float + border glow (disabled when dragging)
- !isDragging && 'animate-subtle-float animate-border-glow',
- 'select-none touch-none',
- hoveredAccount !== null && 'scale-[1.02]',
- isDragging && 'cursor-grabbing shadow-2xl scale-105 z-50'
- )}
- style={
- {
- '--glow-color': `${providerColor}60`,
- borderColor: hoveredAccount !== null ? `${providerColor}80` : undefined,
- transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
- } as React.CSSProperties
- }
- >
- {/* Drag handle */}
-
-
- {/* Animated glow background */}
-
-
- {/* Left Connector Point */}
-
-
- {/* Right Connector Point - only show if there are right accounts */}
- {hasRightAccounts && (
-
- )}
-
- {/* Top Connector Point - only show if there are top accounts */}
- {hasTopAccounts && (
-
- )}
-
- {/* Bottom Connector Point - only show if there are bottom accounts */}
- {hasBottomAccounts && (
-
- )}
-
-
- {/* Provider icon with breathing animation */}
-
-
-
- {providerData.displayName}
-
-
- Provider
-
-
-
-
-
-
- Total Requests
-
- {totalRequests.toLocaleString()}
-
-
-
- Accounts
- {accounts.length}
-
-
-
-
- );
- })()}
-
-
- {/* Right Accounts */}
- {hasRightAccounts && (
-
- {rightAccounts.map((account) => {
- const originalIndex = accounts.findIndex((a) => a.id === account.id);
- const isHovered = hoveredAccount === originalIndex;
- const isDragging = draggingId === account.id;
- const offset = getOffset(account.id);
-
- return (
-
setHoveredAccount(originalIndex)}
- onMouseLeave={() => setHoveredAccount(null)}
- onPointerDown={(e) => handlePointerDown(account.id, e)}
- onPointerMove={handlePointerMove}
- onPointerUp={handlePointerUp}
- onPointerCancel={handlePointerUp}
- className={cn(
- 'group/card relative rounded-lg p-3 pl-4 w-44 cursor-grab transition-shadow duration-200',
- 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
- 'border border-border/50 dark:border-white/[0.08]',
- 'border-r-2 select-none touch-none',
- isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
- isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
- )}
- style={{
- borderRightColor: account.color,
- transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
- }}
- >
- {/* Drag handle indicator */}
-
-
-
- {cleanEmail(account.email)}
-
-
-
- {/* Connector Dot - Left side */}
-
-
- );
- })}
-
- )}
-
-
- {/* Bottom Zone Accounts */}
- {hasBottomAccounts && (
-
- {bottomAccounts.map((account) => {
- const originalIndex = accounts.findIndex((a) => a.id === account.id);
- const isHovered = hoveredAccount === originalIndex;
- const isDragging = draggingId === account.id;
- const offset = getOffset(account.id);
-
- return (
-
setHoveredAccount(originalIndex)}
- onMouseLeave={() => setHoveredAccount(null)}
- onPointerDown={(e) => handlePointerDown(account.id, e)}
- onPointerMove={handlePointerMove}
- onPointerUp={handlePointerUp}
- onPointerCancel={handlePointerUp}
- className={cn(
- 'group/card relative rounded-lg p-3 pt-4 w-44 cursor-grab transition-shadow duration-200',
- 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
- 'border border-border/50 dark:border-white/[0.08]',
- 'border-b-2 select-none touch-none',
- isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
- isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
- )}
- style={{
- borderBottomColor: account.color,
- transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
- }}
- >
- {/* Connector Dot - Top side */}
-
-
-
-
- {cleanEmail(account.email)}
-
-
-
-
- );
- })}
-
- )}
-
-
- {/* Right Section: Connection Timeline - Fixed compact width */}
-
-
-
- );
-}
+export { AccountFlowViz } from './account/flow-viz';
+export type {
+ AccountData,
+ ProviderData,
+ AccountFlowVizProps,
+ ConnectionEvent,
+} from './account/flow-viz';
diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx
new file mode 100644
index 00000000..65865f03
--- /dev/null
+++ b/ui/src/components/account/accounts-table.tsx
@@ -0,0 +1,150 @@
+/**
+ * Accounts Table Component
+ * Phase 03: REST API Routes & CRUD
+ */
+
+import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { Button } from '@/components/ui/button';
+import { Check } from 'lucide-react';
+import { useSetDefaultAccount } from '@/hooks/use-accounts';
+import type { Account } from '@/lib/api-client';
+
+interface AccountsTableProps {
+ data: Account[];
+ defaultAccount: string | null;
+}
+
+export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
+ const setDefaultMutation = useSetDefaultAccount();
+
+ const columns: ColumnDef[] = [
+ {
+ accessorKey: 'name',
+ header: 'Name',
+ size: 200,
+ cell: ({ row }) => (
+
+ {row.original.name}
+ {row.original.name === defaultAccount && (
+
+ default
+
+ )}
+
+ ),
+ },
+ {
+ accessorKey: 'type',
+ header: 'Type',
+ size: 100,
+ cell: ({ row }) => (
+ {row.original.type || 'oauth'}
+ ),
+ },
+ {
+ accessorKey: 'created',
+ header: 'Created',
+ size: 150,
+ cell: ({ row }) => {
+ const date = new Date(row.original.created);
+ return {date.toLocaleDateString()};
+ },
+ },
+ {
+ accessorKey: 'last_used',
+ header: 'Last Used',
+ size: 150,
+ cell: ({ row }) => {
+ if (!row.original.last_used) return -;
+ const date = new Date(row.original.last_used);
+ return {date.toLocaleDateString()};
+ },
+ },
+ {
+ id: 'actions',
+ header: 'Actions',
+ size: 100,
+ cell: ({ row }) => {
+ const isDefault = row.original.name === defaultAccount;
+ return (
+
+ );
+ },
+ },
+ ];
+
+ // eslint-disable-next-line react-hooks/incompatible-library
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ });
+
+ if (data.length === 0) {
+ return (
+
+ No accounts found. Use ccs login to
+ add accounts.
+
+ );
+ }
+
+ return (
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ const widthClass =
+ {
+ name: 'w-[200px]',
+ type: 'w-[100px]',
+ created: 'w-[150px]',
+ last_used: 'w-[150px]',
+ actions: 'w-[100px]',
+ }[header.id] || 'w-auto';
+
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+
+ );
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx
new file mode 100644
index 00000000..fa01fa4e
--- /dev/null
+++ b/ui/src/components/account/add-account-dialog.tsx
@@ -0,0 +1,128 @@
+/**
+ * Add Account Dialog Component
+ * Triggers OAuth flow server-side to add another account to a provider
+ * Applies default preset when adding first account
+ */
+
+import { useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Loader2, ExternalLink, User } from 'lucide-react';
+import { useStartAuth } from '@/hooks/use-cliproxy';
+import { applyDefaultPreset } from '@/lib/preset-utils';
+import { toast } from 'sonner';
+
+interface AddAccountDialogProps {
+ open: boolean;
+ onClose: () => void;
+ provider: string;
+ displayName: string;
+ /** Whether this is the first account being added (triggers preset application) */
+ isFirstAccount?: boolean;
+}
+
+export function AddAccountDialog({
+ open,
+ onClose,
+ provider,
+ displayName,
+ isFirstAccount = false,
+}: AddAccountDialogProps) {
+ const [nickname, setNickname] = useState('');
+ const startAuthMutation = useStartAuth();
+
+ const handleStartAuth = () => {
+ startAuthMutation.mutate(
+ { provider, nickname: nickname.trim() || undefined },
+ {
+ onSuccess: async () => {
+ // Apply default preset if this is the first account
+ if (isFirstAccount) {
+ const result = await applyDefaultPreset(provider);
+ if (result.success && result.presetName) {
+ toast.success(`Applied "${result.presetName}" preset`);
+ } else if (!result.success) {
+ toast.warning('Account added, but failed to apply default preset');
+ }
+ }
+ setNickname('');
+ onClose();
+ },
+ }
+ );
+ };
+
+ const handleOpenChange = (isOpen: boolean) => {
+ if (!isOpen && !startAuthMutation.isPending) {
+ setNickname('');
+ onClose();
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/account/flow-viz/account-card-stats.tsx b/ui/src/components/account/flow-viz/account-card-stats.tsx
new file mode 100644
index 00000000..3b3a18f3
--- /dev/null
+++ b/ui/src/components/account/flow-viz/account-card-stats.tsx
@@ -0,0 +1,85 @@
+/**
+ * Premium compact stats visualization for account cards
+ */
+
+import { cn } from '@/lib/utils';
+import { CheckCircle2, XCircle } from 'lucide-react';
+
+interface AccountCardStatsProps {
+ success: number;
+ failure: number;
+ showDetails: boolean;
+}
+
+export function AccountCardStats({ success, failure, showDetails }: AccountCardStatsProps) {
+ const total = success + failure;
+ const successRate = total > 0 ? (success / total) * 100 : 100;
+
+ return (
+
+ {/* Primary Row: Success Rate & Total */}
+
+
+
+ Success Rate
+
+ = 90
+ ? 'text-amber-500'
+ : 'text-red-500'
+ )}
+ >
+ {Math.round(successRate)}%
+
+
+
+
+ Volume
+
+
+ {total.toLocaleString()}
+
+
+
+
+ {/* Detailed Stats - Collapsible */}
+
+
+
+
+ {success}
+
+
+
0
+ ? 'bg-red-500/5 dark:bg-red-500/10 border-red-500/20'
+ : 'bg-muted/10 border-transparent opacity-40'
+ )}
+ >
+ 0 ? 'text-red-500' : 'text-muted-foreground')}
+ />
+ 0 ? 'text-red-500' : 'text-muted-foreground'
+ )}
+ >
+ {failure}
+
+
+
+
+ );
+}
diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx
new file mode 100644
index 00000000..3defbedc
--- /dev/null
+++ b/ui/src/components/account/flow-viz/account-card.tsx
@@ -0,0 +1,127 @@
+/**
+ * Account Card Component for Flow Visualization
+ */
+
+import { cn } from '@/lib/utils';
+import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
+import { GripVertical } from 'lucide-react';
+
+import type { AccountData, DragOffset } from './types';
+import { cleanEmail } from './utils';
+import { AccountCardStats } from './account-card-stats';
+
+type Zone = 'left' | 'right' | 'top' | 'bottom';
+
+interface AccountCardProps {
+ account: AccountData;
+ zone: Zone;
+ originalIndex: number;
+ isHovered: boolean;
+ isDragging: boolean;
+ offset: DragOffset;
+ showDetails: boolean;
+ privacyMode: boolean;
+ onMouseEnter: () => void;
+ onMouseLeave: () => void;
+ onPointerDown: (e: React.PointerEvent) => void;
+ onPointerMove: (e: React.PointerEvent) => void;
+ onPointerUp: () => void;
+}
+
+const BORDER_SIDE_MAP: Record = {
+ left: 'border-l-2',
+ right: 'border-r-2',
+ top: 'border-t-2',
+ bottom: 'border-b-2',
+};
+
+const CONNECTOR_POSITION_MAP: Record = {
+ left: 'top-1/2 -right-1.5 -translate-y-1/2',
+ right: 'top-1/2 -left-1.5 -translate-y-1/2',
+ top: 'left-1/2 -bottom-1.5 -translate-x-1/2',
+ bottom: 'left-1/2 -top-1.5 -translate-x-1/2',
+};
+
+function getBorderColorStyle(zone: Zone, color: string): React.CSSProperties {
+ switch (zone) {
+ case 'left':
+ return { borderLeftColor: color };
+ case 'right':
+ return { borderRightColor: color };
+ case 'top':
+ return { borderTopColor: color };
+ case 'bottom':
+ return { borderBottomColor: color };
+ }
+}
+
+export function AccountCard({
+ account,
+ zone,
+ originalIndex,
+ isHovered,
+ isDragging,
+ offset,
+ showDetails,
+ privacyMode,
+ onMouseEnter,
+ onMouseLeave,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: AccountCardProps) {
+ const borderSide = BORDER_SIDE_MAP[zone];
+ const borderColor = getBorderColorStyle(zone, account.color);
+ const connectorPosition = CONNECTOR_POSITION_MAP[zone];
+
+ return (
+
+
+
+
+ {cleanEmail(account.email)}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/account/flow-viz/connection-timeline.tsx b/ui/src/components/account/flow-viz/connection-timeline.tsx
new file mode 100644
index 00000000..f7743435
--- /dev/null
+++ b/ui/src/components/account/flow-viz/connection-timeline.tsx
@@ -0,0 +1,107 @@
+/**
+ * Connection Timeline Component - right sidebar panel
+ */
+
+import { cn } from '@/lib/utils';
+import { STATUS_COLORS } from '@/lib/utils';
+import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
+import { Activity } from 'lucide-react';
+
+import type { ConnectionEvent } from './types';
+import { cleanEmail, formatTimelineTime } from './utils';
+
+interface ConnectionTimelineProps {
+ events: ConnectionEvent[];
+ privacyMode: boolean;
+}
+
+export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelineProps) {
+ if (events.length === 0) {
+ return (
+
+
No recent connections
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Connection Timeline
+
+
+
+ {/* Timeline container */}
+
+
+ {/* Vertical line */}
+
+
+ {/* Events */}
+
+ {events.map((event) => {
+ const statusColor =
+ event.status === 'success'
+ ? STATUS_COLORS.success
+ : event.status === 'failed'
+ ? STATUS_COLORS.failed
+ : STATUS_COLORS.degraded;
+
+ return (
+
+ {/* Timeline dot */}
+
+
+ {/* Event content */}
+
+
+
+ {cleanEmail(event.accountEmail)}
+
+
+ {formatTimelineTime(event.timestamp)}
+
+
+
+
+ {event.status}
+
+ {event.latencyMs && (
+
+ {event.latencyMs}ms
+
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/ui/src/components/account/flow-viz/flow-paths.tsx b/ui/src/components/account/flow-viz/flow-paths.tsx
new file mode 100644
index 00000000..810be3b8
--- /dev/null
+++ b/ui/src/components/account/flow-viz/flow-paths.tsx
@@ -0,0 +1,89 @@
+/**
+ * SVG Connection Paths Component
+ */
+
+import type { AccountData } from './types';
+import { getConnectionColor } from './utils';
+
+interface FlowPathsProps {
+ paths: string[];
+ accounts: AccountData[];
+ maxRequests: number;
+ hoveredAccount: number | null;
+ pulsingAccounts: Set;
+}
+
+export function FlowPaths({
+ paths,
+ accounts,
+ maxRequests,
+ hoveredAccount,
+ pulsingAccounts,
+}: FlowPathsProps) {
+ return (
+ <>
+
+
+
+
+
+
+ {paths.map((d, i) => {
+ const account = accounts[i];
+ if (!account) return null;
+
+ const total = account.successCount + account.failureCount;
+ const strokeWidth = Math.max(2, (total / maxRequests) * 10);
+ const isHovered = hoveredAccount === i;
+ const isDimmed = hoveredAccount !== null && hoveredAccount !== i;
+ const isPulsing = pulsingAccounts.has(account.id);
+ const connectionColor = getConnectionColor(i);
+
+ return (
+
+
+ {isPulsing && (
+ <>
+
+
+ >
+ )}
+
+ );
+ })}
+ >
+ );
+}
diff --git a/ui/src/components/account/flow-viz/flow-viz-header.tsx b/ui/src/components/account/flow-viz/flow-viz-header.tsx
new file mode 100644
index 00000000..01065856
--- /dev/null
+++ b/ui/src/components/account/flow-viz/flow-viz-header.tsx
@@ -0,0 +1,61 @@
+/**
+ * Flow Visualization Header Component
+ */
+
+import { cn } from '@/lib/utils';
+import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react';
+
+interface FlowVizHeaderProps {
+ onBack?: () => void;
+ showDetails: boolean;
+ onToggleDetails: () => void;
+ hasCustomPositions: boolean;
+ onResetPositions: () => void;
+}
+
+export function FlowVizHeader({
+ onBack,
+ showDetails,
+ onToggleDetails,
+ hasCustomPositions,
+ onResetPositions,
+}: FlowVizHeaderProps) {
+ return (
+
+ {onBack ? (
+
+ ) : (
+
+ )}
+
+
+ {hasCustomPositions && (
+
+ )}
+
+
+ );
+}
diff --git a/ui/src/components/account/flow-viz/hooks.ts b/ui/src/components/account/flow-viz/hooks.ts
new file mode 100644
index 00000000..635a700d
--- /dev/null
+++ b/ui/src/components/account/flow-viz/hooks.ts
@@ -0,0 +1,183 @@
+/**
+ * Custom hooks for drag and position management
+ */
+
+import { useRef, useState, useEffect, useCallback } from 'react';
+import type { DragOffset, ContainerExpansion } from './types';
+
+interface UseDragPositionsOptions {
+ storageKey: string;
+ onDrag?: () => void;
+}
+
+interface UseDragPositionsReturn {
+ dragOffsets: Record;
+ draggingId: string | null;
+ didDragRef: React.MutableRefObject;
+ handlePointerDown: (id: string, e: React.PointerEvent) => void;
+ handlePointerMove: (e: React.PointerEvent) => void;
+ handlePointerUp: () => void;
+ getOffset: (id: string) => DragOffset;
+ resetPositions: () => void;
+ hasCustomPositions: boolean;
+}
+
+/**
+ * Hook for managing draggable card positions with localStorage persistence
+ */
+export function useDragPositions({
+ storageKey,
+ onDrag,
+}: UseDragPositionsOptions): UseDragPositionsReturn {
+ // Drag state
+ const [draggingId, setDraggingId] = useState(null);
+ const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
+ null
+ );
+ const didDragRef = useRef(false);
+
+ // Load saved positions from localStorage
+ const loadSavedPositions = useCallback((): Record => {
+ try {
+ const saved = localStorage.getItem(storageKey);
+ if (saved) return JSON.parse(saved);
+ } catch {
+ // Ignore parse errors
+ }
+ return {};
+ }, [storageKey]);
+
+ const [dragOffsets, setDragOffsets] = useState>(() =>
+ loadSavedPositions()
+ );
+
+ // Save positions to localStorage when they change
+ useEffect(() => {
+ if (Object.keys(dragOffsets).length > 0) {
+ localStorage.setItem(storageKey, JSON.stringify(dragOffsets));
+ }
+ }, [dragOffsets, storageKey]);
+
+ // Reset positions handler
+ const resetPositions = useCallback(() => {
+ setDragOffsets({});
+ localStorage.removeItem(storageKey);
+ }, [storageKey]);
+
+ // Drag handlers
+ const handlePointerDown = useCallback(
+ (id: string, e: React.PointerEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
+ const offset = dragOffsets[id] || { x: 0, y: 0 };
+ dragStartRef.current = { x: e.clientX, y: e.clientY, offsetX: offset.x, offsetY: offset.y };
+ didDragRef.current = false;
+ setDraggingId(id);
+ },
+ [dragOffsets]
+ );
+
+ const handlePointerMove = useCallback(
+ (e: React.PointerEvent) => {
+ if (!draggingId || !dragStartRef.current) return;
+ const start = dragStartRef.current;
+ const dx = e.clientX - start.x;
+ const dy = e.clientY - start.y;
+ // Track if actual movement occurred (threshold of 3px)
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
+ didDragRef.current = true;
+ }
+ setDragOffsets((prev) => ({
+ ...prev,
+ [draggingId]: {
+ x: start.offsetX + dx,
+ y: start.offsetY + dy,
+ },
+ }));
+ // Notify parent to recalculate paths
+ if (onDrag) {
+ requestAnimationFrame(onDrag);
+ }
+ },
+ [draggingId, onDrag]
+ );
+
+ const handlePointerUp = useCallback(() => {
+ setDraggingId(null);
+ dragStartRef.current = null;
+ }, []);
+
+ // Get offset for a card
+ const getOffset = (id: string): DragOffset => dragOffsets[id] || { x: 0, y: 0 };
+
+ return {
+ dragOffsets,
+ draggingId,
+ didDragRef,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ getOffset,
+ resetPositions,
+ hasCustomPositions: Object.keys(dragOffsets).length > 0,
+ };
+}
+
+/**
+ * Calculate container expansion based on drag offsets
+ */
+export function useContainerExpansion(dragOffsets: Record): ContainerExpansion {
+ let minY = 0,
+ maxY = 0;
+ Object.values(dragOffsets).forEach((offset) => {
+ minY = Math.min(minY, offset.y);
+ maxY = Math.max(maxY, offset.y);
+ });
+ return {
+ paddingTop: Math.max(0, -minY),
+ paddingBottom: Math.max(0, maxY),
+ extraHeight: Math.max(0, Math.abs(minY), Math.abs(maxY)) * 2,
+ };
+}
+
+interface AccountLike {
+ id: string;
+ successCount: number;
+ failureCount: number;
+}
+
+/**
+ * Hook for detecting new activity and triggering pulse animations
+ */
+export function usePulseAnimation(accounts: AccountLike[]): Set {
+ const [pulsingAccounts, setPulsingAccounts] = useState>(new Set());
+ const prevCountsRef = useRef>({});
+
+ // Detect new activity and trigger pulse animation
+ useEffect(() => {
+ const newPulsing = new Set();
+ const newCounts: Record = {};
+
+ accounts.forEach((account) => {
+ const currentCount = account.successCount + account.failureCount;
+ newCounts[account.id] = currentCount;
+ const prev = prevCountsRef.current[account.id];
+ if (prev !== undefined && currentCount > prev) {
+ newPulsing.add(account.id);
+ }
+ });
+
+ prevCountsRef.current = newCounts;
+
+ if (newPulsing.size > 0) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Valid pattern for animation triggers
+ setPulsingAccounts(newPulsing);
+
+ const timer = setTimeout(() => setPulsingAccounts(new Set()), 2000);
+ return () => clearTimeout(timer);
+ }
+ }, [accounts]);
+
+ return pulsingAccounts;
+}
diff --git a/ui/src/components/account/flow-viz/index.tsx b/ui/src/components/account/flow-viz/index.tsx
new file mode 100644
index 00000000..7d1dae00
--- /dev/null
+++ b/ui/src/components/account/flow-viz/index.tsx
@@ -0,0 +1,202 @@
+/**
+ * Account Flow Visualization
+ * Custom SVG bezier curve visualization showing request flow from accounts to providers
+ */
+
+import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
+import { cn } from '@/lib/utils';
+import { PROVIDER_COLORS } from '@/lib/provider-config';
+import { usePrivacy } from '@/contexts/privacy-context';
+
+import type { AccountFlowVizProps } from './types';
+import { MAX_TIMELINE_EVENTS, generateConnectionEvents } from './utils';
+import { calculateBezierPaths } from './path-utils';
+import { splitAccountsIntoZones, getProviderSizeClass } from './zone-utils';
+import { useDragPositions, useContainerExpansion, usePulseAnimation } from './hooks';
+import { ConnectionTimeline } from './connection-timeline';
+import { AccountCard } from './account-card';
+import { ProviderCard } from './provider-card';
+import { FlowPaths } from './flow-paths';
+import { FlowVizHeader } from './flow-viz-header';
+
+// Re-export types for backward compatibility
+export type { AccountData, ProviderData, AccountFlowVizProps, ConnectionEvent } from './types';
+
+export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
+ const containerRef = useRef(null);
+ const svgRef = useRef(null);
+ const [hoveredAccount, setHoveredAccount] = useState(null);
+ const [showDetails, setShowDetails] = useState(false);
+ const [paths, setPaths] = useState([]);
+
+ const { privacyMode } = usePrivacy();
+ const { accounts } = providerData;
+ const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1);
+ const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0);
+
+ const calculatePaths = useCallback(() => {
+ const newPaths = calculateBezierPaths({ containerRef, svgRef, accounts });
+ if (newPaths.length > 0) setPaths(newPaths);
+ }, [accounts]);
+
+ const storageKey = `ccs-flow-positions-${providerData.provider}`;
+ const {
+ dragOffsets,
+ draggingId,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ getOffset,
+ resetPositions,
+ hasCustomPositions,
+ } = useDragPositions({ storageKey, onDrag: calculatePaths });
+ const containerExpansion = useContainerExpansion(dragOffsets);
+ const pulsingAccounts = usePulseAnimation(accounts);
+
+ const connectionEvents = useMemo(
+ () => generateConnectionEvents(accounts).slice(0, MAX_TIMELINE_EVENTS),
+ [accounts]
+ );
+
+ useEffect(() => {
+ const timer = setTimeout(calculatePaths, 50);
+ window.addEventListener('resize', calculatePaths);
+ return () => {
+ clearTimeout(timer);
+ window.removeEventListener('resize', calculatePaths);
+ };
+ }, [calculatePaths]);
+
+ useEffect(() => {
+ const timer = setTimeout(calculatePaths, 10);
+ return () => clearTimeout(timer);
+ }, [dragOffsets, calculatePaths]);
+
+ useEffect(() => {
+ const startTime = Date.now();
+ const duration = 350;
+ const animate = () => {
+ calculatePaths();
+ if (Date.now() - startTime < duration) requestAnimationFrame(animate);
+ };
+ requestAnimationFrame(animate);
+ }, [showDetails, calculatePaths]);
+
+ const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280';
+ const zones = useMemo(() => splitAccountsIntoZones(accounts), [accounts]);
+ const { leftAccounts, rightAccounts, topAccounts, bottomAccounts } = zones;
+ const hasRightAccounts = rightAccounts.length > 0;
+ const hasTopAccounts = topAccounts.length > 0;
+ const hasBottomAccounts = bottomAccounts.length > 0;
+ const providerSize = useMemo(() => getProviderSizeClass(accounts.length), [accounts.length]);
+
+ const renderAccountCards = (
+ accountList: typeof accounts,
+ zone: 'left' | 'right' | 'top' | 'bottom'
+ ) =>
+ accountList.map((account) => {
+ const originalIndex = accounts.findIndex((a) => a.id === account.id);
+ return (
+ setHoveredAccount(originalIndex)}
+ onMouseLeave={() => setHoveredAccount(null)}
+ onPointerDown={(e) => handlePointerDown(account.id, e)}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ />
+ );
+ });
+
+ return (
+
+
setShowDetails(!showDetails)}
+ hasCustomPositions={hasCustomPositions}
+ onResetPositions={resetPositions}
+ />
+
+
+
+
+
+ {hasTopAccounts && (
+
+ {renderAccountCards(topAccounts, 'top')}
+
+ )}
+
+
+
+ {renderAccountCards(leftAccounts, 'left')}
+
+
+
+
handlePointerDown('provider', e)}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ />
+
+
+ {hasRightAccounts && (
+
+ {renderAccountCards(rightAccounts, 'right')}
+
+ )}
+
+
+ {hasBottomAccounts && (
+
+ {renderAccountCards(bottomAccounts, 'bottom')}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/account/flow-viz/path-utils.ts b/ui/src/components/account/flow-viz/path-utils.ts
new file mode 100644
index 00000000..422c8727
--- /dev/null
+++ b/ui/src/components/account/flow-viz/path-utils.ts
@@ -0,0 +1,96 @@
+/**
+ * SVG path calculation utilities for bezier curves
+ */
+
+import type { AccountData, AccountZone } from './types';
+
+interface PathCalculationParams {
+ containerRef: React.RefObject;
+ svgRef: React.RefObject;
+ accounts: AccountData[];
+}
+
+/**
+ * Calculate SVG bezier curve paths from account cards to provider node
+ */
+export function calculateBezierPaths({
+ containerRef,
+ svgRef,
+ accounts,
+}: PathCalculationParams): string[] {
+ if (!containerRef.current || !svgRef.current) return [];
+
+ const container = containerRef.current;
+ const svg = svgRef.current;
+ const svgRect = svg.getBoundingClientRect();
+
+ const destEl = container.querySelector('[data-provider-node]');
+ if (!destEl) return [];
+ const destRect = destEl.getBoundingClientRect();
+
+ const newPaths: string[] = [];
+
+ accounts.forEach((_, i) => {
+ const sourceEl = container.querySelector(`[data-account-index="${i}"]`);
+ if (!sourceEl) return;
+ const sourceRect = sourceEl.getBoundingClientRect();
+
+ // Determine zone from data attribute
+ const zone = (sourceEl.getAttribute('data-zone') || 'left') as AccountZone;
+
+ let startX: number, startY: number, destX: number, destY: number;
+
+ // Note: getBoundingClientRect already includes CSS transforms, so offset is implicit
+
+ switch (zone) {
+ case 'right':
+ // Right side: connect from left edge of card to right edge of provider
+ startX = sourceRect.left - svgRect.left;
+ startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
+ destX = destRect.right - svgRect.left;
+ destY = destRect.top + destRect.height / 2 - svgRect.top;
+ break;
+ case 'top':
+ // Top side: connect from bottom edge of card to top edge of provider
+ startX = sourceRect.left + sourceRect.width / 2 - svgRect.left;
+ startY = sourceRect.bottom - svgRect.top;
+ destX = destRect.left + destRect.width / 2 - svgRect.left;
+ destY = destRect.top - svgRect.top;
+ break;
+ case 'bottom':
+ // Bottom side: connect from top edge of card to bottom edge of provider
+ startX = sourceRect.left + sourceRect.width / 2 - svgRect.left;
+ startY = sourceRect.top - svgRect.top;
+ destX = destRect.left + destRect.width / 2 - svgRect.left;
+ destY = destRect.bottom - svgRect.top;
+ break;
+ default: // 'left'
+ // Left side: connect from right edge of card to left edge of provider
+ startX = sourceRect.right - svgRect.left;
+ startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
+ destX = destRect.left - svgRect.left;
+ destY = destRect.top + destRect.height / 2 - svgRect.top;
+ }
+
+ // Bezier control points - adjust based on zone direction
+ let cp1X: number, cp1Y: number, cp2X: number, cp2Y: number;
+
+ if (zone === 'top' || zone === 'bottom') {
+ // Vertical connection - control points extend horizontally for curve
+ cp1X = startX;
+ cp1Y = startY + (destY - startY) * 0.5;
+ cp2X = destX;
+ cp2Y = destY - (destY - startY) * 0.5;
+ } else {
+ // Horizontal connection - control points extend vertically for curve
+ cp1X = startX + (destX - startX) * 0.5;
+ cp1Y = startY;
+ cp2X = destX - (destX - startX) * 0.5;
+ cp2Y = destY;
+ }
+
+ newPaths.push(`M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${destX} ${destY}`);
+ });
+
+ return newPaths;
+}
diff --git a/ui/src/components/account/flow-viz/provider-card.tsx b/ui/src/components/account/flow-viz/provider-card.tsx
new file mode 100644
index 00000000..95328d94
--- /dev/null
+++ b/ui/src/components/account/flow-viz/provider-card.tsx
@@ -0,0 +1,143 @@
+/**
+ * Provider Card Component for Flow Visualization
+ */
+
+import { cn } from '@/lib/utils';
+import { ProviderIcon } from '@/components/shared/provider-icon';
+import { GripVertical } from 'lucide-react';
+
+import type { DragOffset, ProviderData } from './types';
+
+interface ProviderCardProps {
+ providerData: ProviderData;
+ providerColor: string;
+ totalRequests: number;
+ maxRequests: number;
+ isDragging: boolean;
+ offset: DragOffset;
+ hoveredAccount: number | null;
+ hasRightAccounts: boolean;
+ hasTopAccounts: boolean;
+ hasBottomAccounts: boolean;
+ onPointerDown: (e: React.PointerEvent) => void;
+ onPointerMove: (e: React.PointerEvent) => void;
+ onPointerUp: () => void;
+}
+
+export function ProviderCard({
+ providerData,
+ providerColor,
+ totalRequests,
+ maxRequests,
+ isDragging,
+ offset,
+ hoveredAccount,
+ hasRightAccounts,
+ hasTopAccounts,
+ hasBottomAccounts,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: ProviderCardProps) {
+ const { accounts } = providerData;
+
+ return (
+
+
+
+
+ {/* Connector Points */}
+
+ {hasRightAccounts && (
+
+ )}
+ {hasTopAccounts && (
+
+ )}
+ {hasBottomAccounts && (
+
+ )}
+
+
+
+
+
+ {providerData.displayName}
+
+
Provider
+
+
+
+
+
+ Total Requests
+ {totalRequests.toLocaleString()}
+
+
+ Accounts
+ {accounts.length}
+
+
+
+
+ );
+}
diff --git a/ui/src/components/account/flow-viz/types.ts b/ui/src/components/account/flow-viz/types.ts
new file mode 100644
index 00000000..b6e5ca33
--- /dev/null
+++ b/ui/src/components/account/flow-viz/types.ts
@@ -0,0 +1,57 @@
+/**
+ * Type definitions for Account Flow Visualization
+ */
+
+/** Position offset for draggable cards */
+export interface DragOffset {
+ x: number;
+ y: number;
+}
+
+export interface AccountData {
+ id: string;
+ email: string;
+ provider: string;
+ successCount: number;
+ failureCount: number;
+ lastUsedAt?: string;
+ color: string;
+}
+
+export interface ProviderData {
+ provider: string;
+ displayName: string;
+ totalRequests: number;
+ accounts: AccountData[];
+}
+
+export interface AccountFlowVizProps {
+ providerData: ProviderData;
+ onBack?: () => void;
+}
+
+export interface ConnectionEvent {
+ id: string;
+ timestamp: Date;
+ accountEmail: string;
+ status: 'success' | 'failed' | 'pending';
+ latencyMs?: number;
+}
+
+/** Zone type for account card placement */
+export type AccountZone = 'left' | 'right' | 'top' | 'bottom';
+
+/** Container expansion state */
+export interface ContainerExpansion {
+ paddingTop: number;
+ paddingBottom: number;
+ extraHeight: number;
+}
+
+/** Account zone distribution */
+export interface AccountZones {
+ leftAccounts: AccountData[];
+ rightAccounts: AccountData[];
+ topAccounts: AccountData[];
+ bottomAccounts: AccountData[];
+}
diff --git a/ui/src/components/account/flow-viz/utils.ts b/ui/src/components/account/flow-viz/utils.ts
new file mode 100644
index 00000000..ce101437
--- /dev/null
+++ b/ui/src/components/account/flow-viz/utils.ts
@@ -0,0 +1,87 @@
+/**
+ * Utility functions for Account Flow Visualization
+ */
+
+import type { AccountData, ConnectionEvent } from './types';
+
+// Maximum events to display in the Connection Timeline to prevent performance issues
+export const MAX_TIMELINE_EVENTS = 100;
+
+// Earthy, sophisticated color palette for connection lines - works in both light/dark themes
+export const CONNECTION_COLORS = [
+ '#3b3c36', // Charcoal Brown - urban mystery
+ '#568203', // Forest Moss - woodland depth
+ '#8d4557', // Vintage Berry - timeless elegance
+ '#da9100', // Harvest Gold - sun-drenched warmth
+ '#3c6c82', // Blue Slate - cool authority
+ '#c96907', // Burnt Caramel - earthy comfort
+];
+
+/** Get a muted connection color based on index */
+export function getConnectionColor(index: number): string {
+ return CONNECTION_COLORS[index % CONNECTION_COLORS.length];
+}
+
+/** Strip common email domains for cleaner display */
+export function cleanEmail(email: string): string {
+ return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, '');
+}
+
+/** Format timestamp for timeline display */
+export function formatTimelineTime(date: Date): string {
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+
+ if (diffMins < 1) return 'now';
+ if (diffMins < 60) return `${diffMins}m`;
+ const diffHours = Math.floor(diffMins / 60);
+ if (diffHours < 24) return `${diffHours}h`;
+ return `${Math.floor(diffHours / 24)}d`;
+}
+
+/** Generate connection events from real account data */
+export function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] {
+ const events: ConnectionEvent[] = [];
+
+ accounts.forEach((account) => {
+ const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date();
+
+ // Helper to add events
+ const addEvents = (count: number, status: 'success' | 'failed') => {
+ for (let i = 0; i < count; i++) {
+ // Simulate timestamps:
+ // - Distribute events over a 24-hour window relative to lastUsed
+ // - Add random jitter so events from different accounts mix
+ const timeOffset = Math.floor(Math.random() * 24 * 60 * 60 * 1000 * (i / (count || 1)));
+ const timestamp = new Date(lastUsed.getTime() - timeOffset);
+
+ // Add small random jitter (+/-5 mins) to avoid exact overlaps
+ const jitter = Math.floor((Math.random() - 0.5) * 10 * 60 * 1000);
+ timestamp.setTime(timestamp.getTime() + jitter);
+
+ // Sanity check: don't go into the future relative to "now"
+ const now = new Date();
+ if (timestamp > now) timestamp.setTime(now.getTime());
+
+ events.push({
+ id: `${account.id}-${status}-${i}`,
+ timestamp,
+ accountEmail: account.email,
+ status,
+ // Simulate realistic latency (success: 50-200ms, failed: 200-5000ms)
+ latencyMs:
+ status === 'success'
+ ? 50 + Math.floor(Math.random() * 150)
+ : 200 + Math.floor(Math.random() * 4800),
+ });
+ }
+ };
+
+ addEvents(account.successCount, 'success');
+ addEvents(account.failureCount, 'failed');
+ });
+
+ // Sort by timestamp descending (most recent first)
+ return events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
+}
diff --git a/ui/src/components/account/flow-viz/zone-utils.ts b/ui/src/components/account/flow-viz/zone-utils.ts
new file mode 100644
index 00000000..328103f7
--- /dev/null
+++ b/ui/src/components/account/flow-viz/zone-utils.ts
@@ -0,0 +1,63 @@
+/**
+ * Zone distribution utilities for account cards
+ */
+
+import type { AccountData, AccountZones } from './types';
+
+/**
+ * Split accounts into zones based on count (top/left/right/bottom)
+ */
+export function splitAccountsIntoZones(accounts: AccountData[]): AccountZones {
+ const count = accounts.length;
+
+ // 1-2 accounts: left only
+ if (count <= 2) {
+ return {
+ leftAccounts: accounts,
+ rightAccounts: [],
+ topAccounts: [],
+ bottomAccounts: [],
+ };
+ }
+
+ // 3-4 accounts: left and right
+ if (count <= 4) {
+ const mid = Math.ceil(count / 2);
+ return {
+ leftAccounts: accounts.slice(0, mid),
+ rightAccounts: accounts.slice(mid),
+ topAccounts: [],
+ bottomAccounts: [],
+ };
+ }
+
+ // 5-8 accounts: left, right, top
+ if (count <= 8) {
+ const perZone = Math.ceil(count / 3);
+ return {
+ leftAccounts: accounts.slice(0, perZone),
+ rightAccounts: accounts.slice(perZone, perZone * 2),
+ topAccounts: accounts.slice(perZone * 2),
+ bottomAccounts: [],
+ };
+ }
+
+ // 9+ accounts: all four zones
+ const perZone = Math.ceil(count / 4);
+ return {
+ leftAccounts: accounts.slice(0, perZone),
+ rightAccounts: accounts.slice(perZone, perZone * 2),
+ topAccounts: accounts.slice(perZone * 2, perZone * 3),
+ bottomAccounts: accounts.slice(perZone * 3),
+ };
+}
+
+/**
+ * Get provider card size class based on account count
+ */
+export function getProviderSizeClass(accountCount: number): string {
+ if (accountCount >= 9) return 'w-64'; // 4 zones - largest
+ if (accountCount >= 5) return 'w-60'; // 3 zones
+ if (accountCount >= 3) return 'w-56'; // 2 zones
+ return 'w-52'; // 1 zone - default
+}
diff --git a/ui/src/components/account/index.ts b/ui/src/components/account/index.ts
new file mode 100644
index 00000000..ba0cf9b4
--- /dev/null
+++ b/ui/src/components/account/index.ts
@@ -0,0 +1,11 @@
+/**
+ * Account Components Barrel Export
+ */
+
+// Main components
+export { AccountsTable } from './accounts-table';
+export { AddAccountDialog } from './add-account-dialog';
+
+// Flow visualization (from subdirectory)
+export { AccountFlowViz } from './flow-viz';
+export type { AccountData, ProviderData, AccountFlowVizProps, ConnectionEvent } from './flow-viz';