diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index 05fa86d4..e13aebef 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -233,11 +233,40 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { const { privacyMode } = usePrivacy(); // Drag state for all cards (account IDs + 'provider') - const [dragOffsets, setDragOffsets] = useState>({}); 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()); @@ -296,30 +325,60 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { if (!sourceEl) return; const sourceRect = sourceEl.getBoundingClientRect(); - // Determine if this account is on the right side - const isRightSide = sourceEl.hasAttribute('data-right-side'); + // Determine zone from data attribute + const zone = sourceEl.getAttribute('data-zone') || 'left'; let startX: number, startY: number, destX: number, destY: number; - if (isRightSide) { - // Right side account: connect from left edge 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; - } else { - // Left side account: connect from right edge 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; + // 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 - const cp1X = startX + (destX - startX) * 0.5; - const cp1Y = startY; - const cp2X = destX - (destX - startX) * 0.5; - const cp2Y = destY; + // 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}`); }); @@ -337,21 +396,63 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { }; }, [calculatePaths]); + // Recalculate paths when drag offsets change (including reset) + useEffect(() => { + const timer = setTimeout(calculatePaths, 10); + return () => clearTimeout(timer); + }, [dragOffsets, calculatePaths]); + const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280'; - // Split accounts into left and right groups (when > 1 account, balance both sides) - const { leftAccounts, rightAccounts } = useMemo(() => { - if (accounts.length <= 1) { - return { leftAccounts: accounts, rightAccounts: [] }; + // 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: [] }; } - const mid = Math.ceil(accounts.length / 2); + // 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, mid), - rightAccounts: accounts.slice(mid), + 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; + + // 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( @@ -361,6 +462,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { (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] @@ -372,6 +474,10 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { 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]: { @@ -406,10 +512,10 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { )} - {/* Main visualization area - 3 column layout: Left Accounts | Provider | Right Accounts + Timeline */} -
+ {/* Main visualization area - Multi-zone layout */} +
{/* Flow visualization section */} -
+
{/* SVG Canvas (Background) */} - {/* Left Accounts */} -
- {leftAccounts.map((account) => { - const originalIndex = accounts.findIndex((a) => a.id === account.id); - const total = account.successCount + account.failureCount; - const isHovered = hoveredAccount === originalIndex; - const isDragging = draggingId === account.id; - const offset = getOffset(account.id); - - return ( -
!isDragging && setSelectedAccount(account)} - onMouseEnter={() => 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-6 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)} - - -
-
- - {total.toLocaleString()} reqs - -
- {account.failureCount > 0 && ( -
- )} -
-
-
- {/* 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 && ( -
- )} - -
- {/* Provider icon with breathing animation */} -
- -
-
-

- {providerData.displayName} -

-

- Provider -

-
-
- -
-
- Total Requests - - {totalRequests.toLocaleString()} - -
-
- Accounts - {accounts.length} -
-
-
-
-
-
- ); - })()} -
- - {/* Right Accounts */} - {hasRightAccounts && ( -
- {rightAccounts.map((account) => { + {/* Top Zone Accounts */} + {hasTopAccounts && ( +
+ {topAccounts.map((account) => { const originalIndex = accounts.findIndex((a) => a.id === account.id); const total = account.successCount + account.failureCount; const isHovered = hoveredAccount === originalIndex; @@ -662,8 +593,11 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
!isDragging && setSelectedAccount(account)} + data-zone="top" + onClick={() => + !didDragRef.current && + setSelectedAccount((prev) => (prev?.id === account.id ? null : account)) + } onMouseEnter={() => setHoveredAccount(originalIndex)} onMouseLeave={() => setHoveredAccount(null)} onPointerDown={(e) => handlePointerDown(account.id, e)} @@ -671,27 +605,20 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} className={cn( - 'group/card relative rounded-lg p-3 pl-6 cursor-grab transition-shadow duration-200', + 'group/card relative rounded-lg p-3 pb-5 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', + '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={{ - borderRightColor: account.color, + borderTopColor: account.color, transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, }} > - {/* Drag handle indicator */} - -
- + +
{cleanEmail(account.email)} +
-
-
- {account.failureCount > 0 && ( -
- )} -
{total.toLocaleString()} reqs +
+ {account.failureCount > 0 && ( +
+ )} +
+
- {/* Connector Dot - Left side */} + {/* 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 total = account.successCount + account.failureCount; + const isHovered = hoveredAccount === originalIndex; + const isDragging = draggingId === account.id; + const offset = getOffset(account.id); + + return ( +
+ !didDragRef.current && + setSelectedAccount((prev) => (prev?.id === account.id ? null : account)) + } + onMouseEnter={() => 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-6 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)} + + +
+
+ + {total.toLocaleString()} reqs + +
+ {account.failureCount > 0 && ( +
+ )} +
+
+
+ {/* 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 total = account.successCount + account.failureCount; + const isHovered = hoveredAccount === originalIndex; + const isDragging = draggingId === account.id; + const offset = getOffset(account.id); + + return ( +
+ !didDragRef.current && + setSelectedAccount((prev) => (prev?.id === account.id ? null : account)) + } + onMouseEnter={() => 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-6 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)} + +
+
+
+
+ {account.failureCount > 0 && ( +
+ )} +
+ + {total.toLocaleString()} reqs + +
+ {/* Connector Dot - Left side */} +
+
+ ); + })} +
+ )} +
+ + {/* Bottom Zone Accounts */} + {hasBottomAccounts && ( +
+ {bottomAccounts.map((account) => { + const originalIndex = accounts.findIndex((a) => a.id === account.id); + const total = account.successCount + account.failureCount; + const isHovered = hoveredAccount === originalIndex; + const isDragging = draggingId === account.id; + const offset = getOffset(account.id); + + return ( +
+ !didDragRef.current && + setSelectedAccount((prev) => (prev?.id === account.id ? null : account)) + } + onMouseEnter={() => 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-6 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)} + + +
+
+ + {total.toLocaleString()} reqs + +
+ {account.failureCount > 0 && ( +
+ )} +
+
+
+
+ ); + })} +
+ )}
{/* Right Section: Connection Timeline - Fixed compact width */} -
+
+ {/* Reset Layout Button */} + {Object.keys(dragOffsets).length > 0 && ( +
+ +
+ )} + {/* Detail Panel - slides in from bottom, pushes content */}