From 365f820c55106b7a4c1e2af40637144fb58c7644 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 15:09:16 -0500 Subject: [PATCH 01/15] feat(ui): add multi-zone layout and enhanced drag features to account flow viz - Add 4-zone layout (top/left/right/bottom) based on account count - Dynamic provider card sizing scales with account count - Add localStorage persistence for card positions with reset button - Implement click-to-toggle for account detail panel - Add drag vs click detection to prevent accidental selections - Fix connection curves to properly follow dragged cards using getBoundingClientRect - Add fixed widths (w-44) to all zone cards for consistency - Add top/bottom connector points to provider card --- ui/src/components/account-flow-viz.tsx | 774 +++++++++++++++++-------- 1 file changed, 542 insertions(+), 232 deletions(-) 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 */}
Date: Wed, 17 Dec 2025 20:11:14 +0000 Subject: [PATCH 02/15] chore(release): 6.1.0-dev.1 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index dfda3e0b..67fa07a2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.1.0 +6.1.0-dev.1 diff --git a/package.json b/package.json index cc697cab..f0357ff0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.1.0", + "version": "6.1.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 7b876d23647eb190156e832df0b9bac6b7a6935f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 15:26:25 -0500 Subject: [PATCH 03/15] fix(ui): improve account flow viz layout to fill available width - Change flow viz section from items-center to items-stretch - Use justify-center with gap-8 instead of justify-between for middle row - Add w-full to top/bottom zone containers - Increase gap from gap-3 to gap-4 for consistent spacing Fixes issue where visualization left huge blank spaces --- ui/src/components/account-flow-viz.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index e13aebef..7128b020 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -515,7 +515,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { {/* Main visualization area - Multi-zone layout */}
{/* Flow visualization section */} -
+
{/* SVG Canvas (Background) */} +
{/* Left Accounts */}
{leftAccounts.map((account) => { From ee85a1fd82c570e4c9296c746673c57a140f1677 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 16:40:01 -0500 Subject: [PATCH 04/15] fix(ui): improve account-flow-viz layout and styling --- ui/src/components/account-flow-viz.tsx | 463 ++++++++++++------------- 1 file changed, 227 insertions(+), 236 deletions(-) diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index 7128b020..f61bc906 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -12,12 +12,13 @@ import { STATUS_COLORS } from '@/lib/utils'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { ChevronRight, - X, CheckCircle2, XCircle, - Clock, Activity, GripVertical, + Eye, + EyeOff, + RotateCcw, } from 'lucide-react'; /** Position offset for draggable cards */ @@ -61,20 +62,41 @@ function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] { const events: ConnectionEvent[] = []; accounts.forEach((account) => { - // Only show events for accounts that have actual request data - const hasActivity = account.successCount > 0 || account.failureCount > 0; - if (!hasActivity) return; - - // Create a single consolidated event per account showing its current status const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date(); - const hasFailures = account.failureCount > 0; - events.push({ - id: `${account.id}-status`, - timestamp: lastUsed, - accountEmail: account.email, - status: hasFailures && account.failureCount > account.successCount ? 'failed' : 'success', - }); + // 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) @@ -134,7 +156,7 @@ function ConnectionTimeline({ {/* Events */}
- {events.slice(0, 8).map((event) => { + {events.map((event) => { const statusColor = event.status === 'success' ? STATUS_COLORS.success @@ -186,15 +208,6 @@ function ConnectionTimeline({ ); })}
- - {/* Show more indicator */} - {events.length > 8 && ( -
- - +{events.length - 8} more events - -
- )}
@@ -206,27 +219,93 @@ function cleanEmail(email: string): string { return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); } -function getTimeAgo(dateStr?: string): string { - if (!dateStr) return 'never'; - const date = new Date(dateStr); - if (isNaN(date.getTime())) return 'unknown'; - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - if (diffMs < 0) return 'just now'; - const diffMins = Math.floor(diffMs / 60000); - if (diffMins < 1) return 'just now'; - if (diffMins < 60) return `${diffMins}m ago`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - return `${diffDays}d ago`; +/** 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 [selectedAccount, setSelectedAccount] = useState(null); + const [showDetails, setShowDetails] = useState(false); const [paths, setPaths] = useState([]); // Privacy mode for demo purposes @@ -402,6 +481,21 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { 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) @@ -445,6 +539,21 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { 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; @@ -501,21 +610,56 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { return (
- {/* Back button */} - {onBack && ( - - )} + {/* Header: Back button + Reset Layout */} +
+ {onBack ? ( + + ) : ( +
+ )} +
+ + + {Object.keys(dragOffsets).length > 0 && ( + + )} +
+
{/* Main visualization area - Multi-zone layout */} -
+
{/* Flow visualization section */} -
+
{/* SVG Canvas (Background) */} {topAccounts.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); @@ -594,10 +737,6 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { key={account.id} data-account-index={originalIndex} 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)} @@ -617,8 +756,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, }} > - -
+ +
{cleanEmail(account.email)} - -
-
- - {total.toLocaleString()} reqs - -
- {account.failureCount > 0 && ( -
- )} -
-
+ {/* Connector Dot - Bottom side */}
{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); @@ -675,10 +801,6 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { key={account.id} data-account-index={originalIndex} data-zone="left" - onClick={() => - !didDragRef.current && - setSelectedAccount((prev) => (prev?.id === account.id ? null : account)) - } onMouseEnter={() => setHoveredAccount(originalIndex)} onMouseLeave={() => setHoveredAccount(null)} onPointerDown={(e) => handlePointerDown(account.id, e)} @@ -699,8 +821,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { }} > {/* Drag handle indicator */} - -
+ +
{cleanEmail(account.email)} - -
-
- - {total.toLocaleString()} reqs - -
- {account.failureCount > 0 && ( -
- )} -
-
+ {/* Connector Dot - Right side */}
{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); @@ -877,10 +986,6 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { key={account.id} data-account-index={originalIndex} data-zone="right" - onClick={() => - !didDragRef.current && - setSelectedAccount((prev) => (prev?.id === account.id ? null : account)) - } onMouseEnter={() => setHoveredAccount(originalIndex)} onMouseLeave={() => setHoveredAccount(null)} onPointerDown={(e) => handlePointerDown(account.id, e)} @@ -901,14 +1006,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { }} > {/* Drag handle indicator */} - -
- + +
-
-
-
- {account.failureCount > 0 && ( -
- )} -
- - {total.toLocaleString()} reqs - -
+ {/* Connector Dot - Left side */}
{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); @@ -959,10 +1051,6 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { key={account.id} data-account-index={originalIndex} data-zone="bottom" - onClick={() => - !didDragRef.current && - setSelectedAccount((prev) => (prev?.id === account.id ? null : account)) - } onMouseEnter={() => setHoveredAccount(originalIndex)} onMouseLeave={() => setHoveredAccount(null)} onPointerDown={(e) => handlePointerDown(account.id, e)} @@ -970,7 +1058,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} className={cn( - 'group/card relative rounded-lg p-3 pt-6 w-44 cursor-grab transition-shadow duration-200', + 'group/card relative rounded-lg p-3 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', @@ -990,8 +1078,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { isHovered && 'bg-foreground dark:bg-white border-transparent' )} /> - -
+ +
{cleanEmail(account.email)} - -
-
- - {total.toLocaleString()} reqs - -
- {account.failureCount > 0 && ( -
- )} -
-
+
); })} @@ -1026,94 +1102,9 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
{/* Right Section: Connection Timeline - Fixed compact width */} -
- -
-
- - {/* Reset Layout Button */} - {Object.keys(dragOffsets).length > 0 && ( -
- -
- )} - - {/* Detail Panel - slides in from bottom, pushes content */} -
-
-
- - - {selectedAccount && ( -
- {/* Account Info */} -
-
-
- - {cleanEmail(selectedAccount.email)} - -
-
- Source Account -
-
- - {/* Stats */} -
-
- - SUCCESSFUL -
-
- {selectedAccount.successCount.toLocaleString()} -
-
- -
-
- - FAILED -
-
- {selectedAccount.failureCount.toLocaleString()} -
-
- -
-
- - LAST SYNC -
-
- {getTimeAgo(selectedAccount.lastUsedAt)} -
-
-
- )} +
+
+
From 2bf7992a8a6cb316b252c6b6e20df7860b1b2b8e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 16:40:05 -0500 Subject: [PATCH 05/15] feat(ui): extend privacy mode to blur cost/token values in analytics --- .../analytics/cache-efficiency-card.tsx | 24 +++++++-- .../analytics/model-breakdown-chart.tsx | 11 ++-- .../analytics/model-details-content.tsx | 13 +++-- .../analytics/session-stats-card.tsx | 11 ++-- .../analytics/token-breakdown-chart.tsx | 12 ++++- .../analytics/usage-summary-cards.tsx | 19 ++++++- .../analytics/usage-trend-chart.tsx | 52 ++++++++++++++++--- ui/src/components/cliproxy-stats-overview.tsx | 14 ++++- ui/src/pages/analytics.tsx | 19 +++++-- 9 files changed, 146 insertions(+), 29 deletions(-) diff --git a/ui/src/components/analytics/cache-efficiency-card.tsx b/ui/src/components/analytics/cache-efficiency-card.tsx index eb04bff4..c079c659 100644 --- a/ui/src/components/analytics/cache-efficiency-card.tsx +++ b/ui/src/components/analytics/cache-efficiency-card.tsx @@ -2,7 +2,7 @@ * Cache Efficiency Card Component * * Displays cache usage metrics including hit rate, savings estimate, - * and cache read/write breakdown. + * and cache read/write breakdown. Respects privacy mode to blur sensitive data. */ import { useMemo } from 'react'; @@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Database, TrendingUp, Zap } from 'lucide-react'; import type { UsageSummary } from '@/hooks/use-usage'; import { cn } from '@/lib/utils'; +import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; interface CacheEfficiencyCardProps { data: UsageSummary | undefined; @@ -19,6 +20,8 @@ interface CacheEfficiencyCardProps { } export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficiencyCardProps) { + const { privacyMode } = usePrivacy(); + const metrics = useMemo(() => { if (!data) return null; @@ -93,7 +96,9 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie
- ${metrics.estimatedSavings.toFixed(2)} + + ${metrics.estimatedSavings.toFixed(2)} +

Estimated Savings @@ -106,21 +111,30 @@ export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficie

- {metrics.cacheHitRate.toFixed(0)}% + + {metrics.cacheHitRate.toFixed(0)}% +

Hit Rate

{/* Cache Cost */}
- ${metrics.cacheCost.toFixed(2)} + + ${metrics.cacheCost.toFixed(2)} +

Cache Cost

{/* Cache breakdown bar */}
-
+
Reads: {formatCompact(metrics.totalCacheReads)} Writes: {formatCompact(metrics.totalCacheWrites)}
diff --git a/ui/src/components/analytics/model-breakdown-chart.tsx b/ui/src/components/analytics/model-breakdown-chart.tsx index 9ef39a0e..b08e43f1 100644 --- a/ui/src/components/analytics/model-breakdown-chart.tsx +++ b/ui/src/components/analytics/model-breakdown-chart.tsx @@ -2,7 +2,7 @@ * Model Breakdown Chart Component * * Displays usage distribution by model using pie chart. - * Shows tokens, cost, and percentage breakdown. + * Shows tokens, cost, and percentage breakdown. Respects privacy mode. */ import { useMemo } from 'react'; @@ -10,6 +10,7 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'; import { Skeleton } from '@/components/ui/skeleton'; import type { ModelUsage } from '@/hooks/use-usage'; import { cn, getModelColor } from '@/lib/utils'; +import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; interface ModelBreakdownChartProps { data: ModelUsage[]; @@ -18,6 +19,8 @@ interface ModelBreakdownChartProps { } export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdownChartProps) { + const { privacyMode } = usePrivacy(); + const chartData = useMemo(() => { if (!data || data.length === 0) return []; @@ -54,10 +57,12 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo return (

{item.name}

-

+

{formatNumber(item.value)} ({item.percentage.toFixed(1)}%)

-

${item.cost.toFixed(4)}

+

+ ${item.cost.toFixed(4)} +

); }; diff --git a/ui/src/components/analytics/model-details-content.tsx b/ui/src/components/analytics/model-details-content.tsx index 2b813283..4aca6511 100644 --- a/ui/src/components/analytics/model-details-content.tsx +++ b/ui/src/components/analytics/model-details-content.tsx @@ -1,12 +1,15 @@ import { Badge } from '@/components/ui/badge'; import { ArrowDownRight, ArrowUpRight, Database, Gauge, Sparkles } from 'lucide-react'; import type { ModelUsage } from '@/hooks/use-usage'; +import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { cn } from '@/lib/utils'; interface ModelDetailsContentProps { model: ModelUsage; } export function ModelDetailsContent({ model }: ModelDetailsContentProps) { + const { privacyMode } = usePrivacy(); const ioRatioStatus = getIoRatioStatus(model.ioRatio); return ( @@ -32,11 +35,15 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) { {/* Stats Grid */}
-

${model.cost.toFixed(2)}

+

+ ${model.cost.toFixed(2)} +

Total Cost

-

{formatCompactNumber(model.tokens)}

+

+ {formatCompactNumber(model.tokens)} +

Total Tokens

@@ -46,7 +53,7 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
Token Breakdown
-
+
{ if (!data?.sessions || data.sessions.length === 0) return null; @@ -104,7 +107,9 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
- ${stats.avgCost.toFixed(2)} + + ${stats.avgCost.toFixed(2)} +

Avg Cost/Session @@ -132,7 +137,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar {formatDistanceToNow(new Date(session.lastActivity), { addSuffix: true })}

-
+
${session.cost.toFixed(2)}
{formatCompact(session.inputTokens + session.outputTokens)} toks diff --git a/ui/src/components/analytics/token-breakdown-chart.tsx b/ui/src/components/analytics/token-breakdown-chart.tsx index 01f90913..24817750 100644 --- a/ui/src/components/analytics/token-breakdown-chart.tsx +++ b/ui/src/components/analytics/token-breakdown-chart.tsx @@ -2,7 +2,7 @@ * Token Breakdown Chart Component * * Displays token usage breakdown by type (input, output, cache). - * Shows stacked bar chart with cost breakdown. + * Shows stacked bar chart with cost breakdown. Respects privacy mode. */ import { useMemo } from 'react'; @@ -19,6 +19,7 @@ import { import { Skeleton } from '@/components/ui/skeleton'; import type { TokenBreakdown } from '@/hooks/use-usage'; import { cn } from '@/lib/utils'; +import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; interface TokenBreakdownChartProps { data?: TokenBreakdown; @@ -34,6 +35,8 @@ const COLORS = { }; export function TokenBreakdownChart({ data, isLoading, className }: TokenBreakdownChartProps) { + const { privacyMode } = usePrivacy(); + const chartData = useMemo(() => { if (!data) return []; @@ -151,7 +154,12 @@ export function TokenBreakdownChart({ data, isLoading, className }: TokenBreakdo {/* Cost breakdown summary */} -
+
{chartData.map((item) => (
diff --git a/ui/src/components/analytics/usage-summary-cards.tsx b/ui/src/components/analytics/usage-summary-cards.tsx index cde345f2..301df4ab 100644 --- a/ui/src/components/analytics/usage-summary-cards.tsx +++ b/ui/src/components/analytics/usage-summary-cards.tsx @@ -3,6 +3,7 @@ * * Displays key metrics in a card grid layout. * Shows total tokens, cost, cache tokens, and average cost per day. + * Respects privacy mode to blur sensitive financial data. */ import { Card, CardContent } from '@/components/ui/card'; @@ -10,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { DollarSign, Database, FileText, ArrowDownRight, ArrowUpRight } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { UsageSummary } from '@/hooks/use-usage'; +import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; interface UsageSummaryCardsProps { data?: UsageSummary; @@ -17,6 +19,8 @@ interface UsageSummaryCardsProps { } export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { + const { privacyMode } = usePrivacy(); + if (isLoading) { return (
@@ -100,9 +104,20 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {

{card.title}

-

{card.format(card.value)}

+

+ {card.format(card.value)} +

{card.subtitle && ( -

{card.subtitle}

+

+ {card.subtitle} +

)}
diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index d4bc7eeb..c3a47653 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -3,6 +3,7 @@ * * Displays usage trends over time with tokens and cost. * Supports daily, hourly, and monthly granularity with interactive tooltips. + * Respects privacy mode to blur sensitive data. */ import { useMemo } from 'react'; @@ -19,6 +20,7 @@ import { format } from 'date-fns'; import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; import type { DailyUsage, HourlyUsage } from '@/hooks/use-usage'; +import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; type ChartData = DailyUsage | HourlyUsage; @@ -35,6 +37,8 @@ export function UsageTrendChart({ granularity = 'daily', className, }: UsageTrendChartProps) { + const { privacyMode } = usePrivacy(); + const chartData = useMemo(() => { if (!data || data.length === 0) return []; @@ -66,6 +70,35 @@ export function UsageTrendChart({ ); } + // Custom tick component for privacy-aware axis labels + const PrivacyTick = ({ + x, + y, + payload, + isRight, + }: { + x: number; + y: number; + payload: { value: string | number }; + isRight?: boolean; + }) => { + const displayValue = isRight ? `$${payload.value}` : formatNumber(Number(payload.value)); + + return ( + + {displayValue} + + ); + }; + return (
@@ -93,19 +126,17 @@ export function UsageTrendChart({ } tickLine={false} axisLine={{ className: 'stroke-muted' }} - tickFormatter={(value) => formatNumber(value)} /> } tickLine={false} axisLine={{ className: 'stroke-muted' }} - tickFormatter={(value) => `$${value}`} />

{label}

{payload.map((entry, index) => ( -

+

{entry.name}:{' '} {entry.name === 'Tokens' ? formatNumber(Number(entry.value) || 0) @@ -125,7 +160,12 @@ export function UsageTrendChart({

))} {'requests' in tooltipData && ( -

+

Requests: {tooltipData.requests}

)} diff --git a/ui/src/components/cliproxy-stats-overview.tsx b/ui/src/components/cliproxy-stats-overview.tsx index b6ff362c..0e871eb9 100644 --- a/ui/src/components/cliproxy-stats-overview.tsx +++ b/ui/src/components/cliproxy-stats-overview.tsx @@ -8,6 +8,7 @@ * - Token usage with cost estimation * - Model breakdown with usage distribution * - Session history + * Respects privacy mode to blur sensitive data. */ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -26,12 +27,14 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; +import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; interface CliproxyStatsOverviewProps { className?: string; } export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps) { + const { privacyMode } = usePrivacy(); const { data: status, isLoading: statusLoading } = useCliproxyStatus(); const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running); @@ -216,8 +219,15 @@ export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps)

Total Tokens

-

{formatNumber(totalTokens)}

-

+

+ {formatNumber(totalTokens)} +

+

~${estimateCost(totalTokens).toFixed(2)} estimated

diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index 3c3e9d64..520d385d 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -30,7 +30,8 @@ import { useSessions, type ModelUsage, } from '@/hooks/use-usage'; -import { getModelColor } from '@/lib/utils'; +import { getModelColor, cn } from '@/lib/utils'; +import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; // Format token count to human-readable (K/M/B) function formatTokens(num: number): string { @@ -41,6 +42,8 @@ function formatTokens(num: number): string { } export function AnalyticsPage() { + const { privacyMode } = usePrivacy(); + // Default to last 30 days const [dateRange, setDateRange] = useState({ from: subDays(new Date(), 30), @@ -248,11 +251,21 @@ export function AnalyticsPage() {
{/* Token count */} - + {formatTokens(model.tokens)} {/* Total cost */} - + ${model.cost.toFixed(2)} From 2a69c9beb8864b25066df155367f12e1297633ae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Dec 2025 21:41:41 +0000 Subject: [PATCH 06/15] chore(release): 6.1.0-dev.2 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 67fa07a2..4fd09c4e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.1.0-dev.1 +6.1.0-dev.2 diff --git a/package.json b/package.json index f0357ff0..8a069b9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.1.0-dev.1", + "version": "6.1.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 925ac8e1d47066bf6bfa2e9a36a31ee972a1beb8 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 16:51:23 -0500 Subject: [PATCH 07/15] feat(cliproxy): remove thinking token cap and update agy haiku model - Removed MAX_THINKING_TOKENS (8191) limit for Claude models via agy. - Updated default agy haiku model to gemini-3-flash-preview. - Updated unit tests to match new model expectations. --- config/base-agy.settings.json | 2 +- src/cliproxy/model-config.ts | 36 +----------------------- tests/unit/cliproxy/model-config.test.js | 4 +-- 3 files changed, 4 insertions(+), 38 deletions(-) diff --git a/config/base-agy.settings.json b/config/base-agy.settings.json index b84cf134..0b08be0b 100644 --- a/config/base-agy.settings.json +++ b/config/base-agy.settings.json @@ -5,6 +5,6 @@ "ANTHROPIC_MODEL": "gemini-3-pro-preview", "ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-3-pro-preview", "ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-3-pro-preview", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash" + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-3-flash-preview" } } diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index b3fbebcd..ab9e62b5 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -13,20 +13,6 @@ import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator'; import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; -/** - * Check if model is a Claude model routed via Antigravity - * Claude models require MAX_THINKING_TOKENS < 8192 for thinking to work - */ -function isClaudeModel(modelId: string): boolean { - return modelId.includes('claude'); -} - -/** - * Max thinking tokens for Claude models via Antigravity - * Must be < 8192 due to Google protocol conversion limitations - */ -const CLAUDE_MAX_THINKING_TOKENS = '8191'; - /** CCS directory */ const CCS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.ccs'); @@ -163,8 +149,7 @@ export async function configureProviderModel( // Build settings with selective merge: // - Preserve ALL user settings (top-level and env vars) - // - Only update CCS-controlled fields (model selection + thinking toggle for Claude) - const isClaude = isClaudeModel(selectedModel); + // - Only update CCS-controlled fields (model selection) // CCS-controlled env vars (always override with our values) const ccsControlledEnv: Record = { @@ -176,22 +161,12 @@ export async function configureProviderModel( ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || '', }; - // Claude models require MAX_THINKING_TOKENS < 8192 for thinking to work - if (isClaude) { - ccsControlledEnv.MAX_THINKING_TOKENS = CLAUDE_MAX_THINKING_TOKENS; - } - // Merge: user env vars (preserved) + CCS controlled (override) const mergedEnv = { ...existingEnv, ...ccsControlledEnv, }; - // Remove MAX_THINKING_TOKENS when switching away from Claude model - if (!isClaude && mergedEnv.MAX_THINKING_TOKENS) { - delete mergedEnv.MAX_THINKING_TOKENS; - } - // Build final settings: preserve user top-level settings + update env const settings: Record = { ...existingSettings, @@ -222,15 +197,6 @@ export async function configureProviderModel( console.error(dim(` ${reason}`)); console.error(dim(' Consider using a non-deprecated model for better compatibility.')); } - - // Show info for Claude models about thinking token limit - if (isClaude) { - console.error(''); - console.error( - info(`MAX_THINKING_TOKENS set to ${CLAUDE_MAX_THINKING_TOKENS} (required < 8192)`) - ); - console.error(dim(' Google protocol conversion requires this limit for thinking to work.')); - } console.error(''); return true; diff --git a/tests/unit/cliproxy/model-config.test.js b/tests/unit/cliproxy/model-config.test.js index 807dad9b..ec6f3891 100644 --- a/tests/unit/cliproxy/model-config.test.js +++ b/tests/unit/cliproxy/model-config.test.js @@ -115,7 +115,7 @@ describe('Model Config', () => { ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash-preview', }, }; @@ -138,7 +138,7 @@ describe('Model Config', () => { ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash-preview', }, }; From 1eec1bb4d2e904579a385eab380e6dbabd1f5e17 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Dec 2025 21:52:29 +0000 Subject: [PATCH 08/15] chore(release): 6.1.0-dev.3 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 4fd09c4e..9b4eeff2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.1.0-dev.2 +6.1.0-dev.3 diff --git a/package.json b/package.json index 8a069b9a..b6168f92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.1.0-dev.2", + "version": "6.1.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 572703f4399ae49dc73c3e1bdf00611a859a0f0f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 18:04:50 -0500 Subject: [PATCH 09/15] fix(ui): optimize bundle size and fix calendar crash - optimize vite manualChunks to reduce bundle sizes - update calendar to react-day-picker v9 API - fix calendar crash by removing broken drag-to-select - improve calendar UI with compact styling and better navigation - resolve fast-refresh lint error by separating button variants --- .../analytics/date-range-filter.tsx | 138 +++++++++++------- ui/src/components/ui/calendar.tsx | 90 ++++++++++++ ui/vite.config.ts | 15 ++ 3 files changed, 187 insertions(+), 56 deletions(-) create mode 100644 ui/src/components/ui/calendar.tsx diff --git a/ui/src/components/analytics/date-range-filter.tsx b/ui/src/components/analytics/date-range-filter.tsx index e2dca7f1..fd0730e0 100644 --- a/ui/src/components/analytics/date-range-filter.tsx +++ b/ui/src/components/analytics/date-range-filter.tsx @@ -2,16 +2,18 @@ * Date Range Filter Component * * Provides date range selection with preset options for analytics. - * Uses react-day-picker for date selection UI. + * Uses react-day-picker for date selection UI within a Popover. */ import React from 'react'; -import { format } from 'date-fns'; -import type { DateRange } from 'react-day-picker'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { cn } from '@/lib/utils'; +import { format, subDays } from 'date-fns'; import { CalendarIcon } from 'lucide-react'; +import type { DateRange } from 'react-day-picker'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; interface DateRangeFilterProps { value?: DateRange; @@ -26,70 +28,94 @@ interface DateRangeFilterProps { export function DateRangeFilter({ value, onChange, - presets = [], + presets = [ + { + label: 'Last 7 days', + range: { + from: subDays(new Date(), 7), + to: new Date(), + }, + }, + { + label: 'Last 30 days', + range: { + from: subDays(new Date(), 30), + to: new Date(), + }, + }, + { + label: 'Last 90 days', + range: { + from: subDays(new Date(), 90), + to: new Date(), + }, + }, + ], className, }: DateRangeFilterProps) { - const handlePresetClick = (range: DateRange) => { - onChange(range); - }; + const [isOpen, setIsOpen] = React.useState(false); - const handleFromChange = (e: React.ChangeEvent) => { - const from = e.target.value ? new Date(e.target.value) : undefined; - onChange({ from, to: value?.to }); - }; + // Helper to check if a preset is currently selected + const isPresetSelected = (presetRange: DateRange) => { + if (!value || !value.from || !value.to || !presetRange.from || !presetRange.to) { + return false; + } - const handleToChange = (e: React.ChangeEvent) => { - const to = e.target.value ? new Date(e.target.value) : undefined; - onChange({ from: value?.from, to }); + // Compare dates (ignoring time components if needed, but day-picker usually returns start of day) + // Simple comparison using formatted strings or timestamps + return ( + format(value.from, 'yyyy-MM-dd') === format(presetRange.from, 'yyyy-MM-dd') && + format(value.to, 'yyyy-MM-dd') === format(presetRange.to, 'yyyy-MM-dd') + ); }; return ( -
- {/* Preset Buttons */} - {presets.map((preset, index) => ( +
+ {presets.map((preset) => ( ))} - - {/* Custom Date Range Inputs */} -
-
- - + + + + + -
- to - -
+ +
); } - -// Helper to compare date ranges -function isSameRange(a?: DateRange, b?: DateRange): boolean { - if (!a || !b) return a === b; - - const fromA = a.from?.getTime() ?? 0; - const fromB = b.from?.getTime() ?? 0; - const toA = a.to?.getTime() ?? 0; - const toB = b.to?.getTime() ?? 0; - - return fromA === fromB && toA === toB; -} diff --git a/ui/src/components/ui/calendar.tsx b/ui/src/components/ui/calendar.tsx new file mode 100644 index 00000000..0c8b98c8 --- /dev/null +++ b/ui/src/components/ui/calendar.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from 'lucide-react'; +import { DayPicker } from 'react-day-picker'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button-variants'; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { + return ( + { + const Icon = + orientation === 'left' + ? ChevronLeft + : orientation === 'right' + ? ChevronRight + : orientation === 'up' + ? ChevronUp + : ChevronDown; + return ; + }, + }} + {...props} + /> + ); +} +Calendar.displayName = 'Calendar'; + +export { Calendar }; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index dad4049f..d905eb63 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -23,16 +23,31 @@ export default defineConfig({ 'react-vendor': ['react', 'react-dom', 'react-router-dom'], 'radix-ui': [ '@radix-ui/react-alert-dialog', + '@radix-ui/react-checkbox', + '@radix-ui/react-collapsible', '@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', '@radix-ui/react-label', + '@radix-ui/react-popover', + '@radix-ui/react-scroll-area', + '@radix-ui/react-select', '@radix-ui/react-separator', '@radix-ui/react-slot', + '@radix-ui/react-switch', + '@radix-ui/react-tabs', '@radix-ui/react-tooltip', ], 'tanstack': ['@tanstack/react-query', '@tanstack/react-table'], 'form-utils': ['react-hook-form', '@hookform/resolvers', 'zod'], 'icons': ['lucide-react'], + // Charts - large library, separate chunk + 'charts': ['recharts'], + // Code editor / syntax highlighting + 'code-highlight': ['prism-react-renderer'], + // Notifications + 'notifications': ['sonner'], + // Utilities + 'utils': ['date-fns', 'clsx', 'class-variance-authority', 'tailwind-merge', 'yaml'], }, }, }, From e0bf535bc0fd848389033f8c1b647d03a74ebc88 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Dec 2025 23:06:06 +0000 Subject: [PATCH 10/15] chore(release): 6.1.0-dev.4 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 9b4eeff2..982593cc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.1.0-dev.3 +6.1.0-dev.4 diff --git a/package.json b/package.json index b6168f92..713ce51a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.1.0-dev.3", + "version": "6.1.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 740f31b1b23e57a5eafbc299f17a03f17f89e003 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 18:14:35 -0500 Subject: [PATCH 11/15] style(ui): use earthy sophisticated colors for flow connection lines Replace generic gray connection lines with curated earthy palette (Charcoal Brown, Forest Moss, Vintage Berry, Harvest Gold, Blue Slate, Burnt Caramel) for more visually interesting bezier curves in account flow visualization. --- ui/src/components/account-flow-viz.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index f61bc906..f17b7720 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -21,6 +21,21 @@ import { RotateCcw, } from 'lucide-react'; +// 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; @@ -678,6 +693,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { const isHovered = hoveredAccount === i; const isDimmed = hoveredAccount !== null && hoveredAccount !== i; const isPulsing = pulsingAccounts.has(account.id); + // Use muted connection colors for gentler appearance + const connectionColor = getConnectionColor(i); return ( @@ -685,9 +702,9 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { Date: Wed, 17 Dec 2025 23:15:59 +0000 Subject: [PATCH 12/15] chore(release): 6.1.0-dev.5 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 982593cc..cdcf3aeb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.1.0-dev.4 +6.1.0-dev.5 diff --git a/package.json b/package.json index 713ce51a..5a3f2dcc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.1.0-dev.4", + "version": "6.1.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From ab4c95bac9a64145ab24ccc1f8f143be4f64d06b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 18:33:58 -0500 Subject: [PATCH 13/15] fix(ui): unify account card padding and fix SVG glow filter - add filterUnits="userSpaceOnUse" to flow-glow filter for consistent rendering - unify connector-side padding to p*-4 across all zones (top/bottom/left/right) - previous: top pb-5, left pr-6, right pl-6, bottom no extra - now: all zones have consistent 16px padding on connector side --- ui/src/components/account-flow-viz.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index f17b7720..01c3f766 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -681,7 +681,14 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { className="absolute inset-0 w-full h-full pointer-events-none z-0 overflow-visible" > - + @@ -761,7 +768,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} className={cn( - 'group/card relative rounded-lg p-3 pb-5 w-44 cursor-grab transition-shadow duration-200', + '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', @@ -825,7 +832,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} className={cn( - 'group/card relative rounded-lg p-3 pr-6 w-44 cursor-grab transition-shadow duration-200', + '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', @@ -1010,7 +1017,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} className={cn( - 'group/card relative rounded-lg p-3 pl-6 w-44 cursor-grab transition-shadow duration-200', + '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', @@ -1075,7 +1082,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} className={cn( - 'group/card relative rounded-lg p-3 w-44 cursor-grab transition-shadow duration-200', + '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', From 9028b742f8f764217ccb04335d94c88966cbbb71 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 18:57:12 -0500 Subject: [PATCH 14/15] feat(ui): add ClaudeKit badge and Sponsor buttons to navbar - replace "CCS Config" header text with two promotional buttons - add ClaudeKit badge with logo linking to claudekit.cc - add Sponsor button with heart icon linking to GitHub Sponsors - both buttons feature hover effects and consistent styling - fix dark theme hover text visibility --- ui/public/logos/claudekit-logo.png | Bin 0 -> 3184 bytes ui/src/components/claudekit-badge.tsx | 49 ++++++++++++++++++++++++++ ui/src/components/layout.tsx | 7 +++- ui/src/components/sponsor-button.tsx | 46 ++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 ui/public/logos/claudekit-logo.png create mode 100644 ui/src/components/claudekit-badge.tsx create mode 100644 ui/src/components/sponsor-button.tsx diff --git a/ui/public/logos/claudekit-logo.png b/ui/public/logos/claudekit-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..22c9fecc5883ec70b09b3be188d9b5c170787b7c GIT binary patch literal 3184 zcmZ{mc{CeZyT+p>6b%|6U_5Ag|dna3(8(!qR%n1MhE~1T4 zcg|Gl-{JtC?RMu563^rs#^{b201zq-07S(B0EcH*)G`1Nbarvo4FJ&02LOcpi(0MV zX9qxc6GIe$`R^+0sLnZaF8CWc1ONbB;D3t+@T^Gq%w!Klo9VN!aPtX4R1djOXC#*v z8l`LFMXf4W$Pw+g?MGIJ{kidT{`ZEvyT&eZZ+IVF*CGml+lqv>Zr{ERyu>e`+^fe8 zXK8?2!nb7@?3VlzJUaKXb!=-=QwQHM#N#5d15j83M3&FAaGgquF6W)}pX^a{Lg6p|c8)qtK3XM~n(l^$;PG>dAzfP5 zQWGpc>V9>OYt{Z>#3q*Up{--tb4qaf|i_hknPT}^IbIArz zDz3%RdYnmjOI$ZpkEL0hEa|@A7dZ6%Gx9`-FfwSDh)L7KAF00&_nSUQAr})lH zznLr~;!_)u`i?QVYQdWQA>u{q0&>%wB7HtsmE|I5ZY$3QM>yX_vLQ<(>{L!O$#gej zZqEUD3@)^k((UVtPoSy6*wROhE7y3Tm9;0!4wAW8vye+Kt50+Z(Nc0zLU|&Cqb12z zTPA#{NA7gqo#-ihhKLskz=p~6s3CZ!`=gZ};NxAhqZ;|B&!rNBz{$dDwC*wH48YzLlv z{gH9KOzh+K`>-~Z?&A% zyw&v!zExBLHBt1}K|*~wqPOAJJr+K_jc+%vAcG5zc>WAnlnM}t_JOdbvQ*hR81v#U zk-Z2hkyydKLxug~mtPBA9#R@rnG!1$b#?TkL_rkys=&t8RX816<@IO{(*u=LubmumkUuvhPEp&U%V#1 zk4x(25T4|X;GmV?@K1aUDy_;t62BJmmEQ?Zbz5;g1vS56)gj`itlJxxdo6lKUCG6_ zDekr((2j)#P+u+|zf8)96yM#LUDUQey8_HFYyEzjsVVgEtG9t?1{5B^FAy90bkNy9 zl&!u0D&iC4k4XGH57KSth?(T%q?>k;R-jc=2V^`A2U3nTrz@?pjTGf?M37C3R@lZA z{IbcNr5o9X0)4`Nsy9#n+{uS;%Yix=0tOZ(ziCQ+M+J%D?=Mod*0R8XuKU0*Gq}5* zOrH0Xv%*-bh8p3%xV{=vmy|`f3>V|#f)9V%0AQSxHLR@?4=MBJoGRD7%c zAU7h9Rk8=pOO)@A9Wf|b>s2-K16fOHC%dWpWHoh4N1CgvZW>E<5(i26MOG#tfLxF6 znL{<^wK@2FXGgM;|K;4T zYoPqlLzswmIO|S^q*_0G&L!n4f)xE0a_>*|FQ>yB1gu8zg=dhzW{Zou;HM7%iJ~h}>IKDb*8;R3B^qC4%ahdXb_tZm4skap4O{*;$PzHKopwL{i3pCEQWl zgPtb2jfSOe_VAT@X2PolP+Pu=5cnNrO6g#Tz5+kdSADAPc*)>vd;*(o+dbpH(aIQ# zP}gK)z6+wca>w)J=kFh<)N6rUZ~2VD**D4F1jRD$s|sWKOoaZA^fa0StO4_g;aico zu>8FM=2#ibPuLs$JZ&bbH(abQy>Wi>4SQz0of792y6e5Y&QnqJUgo(BkpEELpv`>b z-|zEr)u3o$n+Oo%AC~4u3QY@tNHj}v(TxjS)HGopH+wu-hBIx4CIaQ+c#m1TszeHz zCz#{mCrLkj=^GU#QayK@?P@b@<-zrZ22kzu#iP7SCgPT;+{taEJDnDuL?;`u*kd2U z=q)G3o&D&ZkA4w~99CTM(F?nWz%N(moAgMIur}TUUDpEZI+$#)O~a2SVBK`>(d%&) zg6wv#lvIq8q2S6VT;Gg5w?HH*lbeTK;=_?5G;UPLrB4ig)dxC8qxu@>c`|k( z3?!4s=&^icvSQ7~#CcOiwf;Yx>Wr#;KUP>onf<1^6)zHE&|2Z+99~=>x&8SCr9@_S zjj#VYhFiLEdNFQoDk!gW4_9R03JDht^VeiZFt!~8SEV;L{Z6kwKE2x72L5$(=eHJT z3Gug)2SSmjc1Iei#YK|ErM-#^bw%p1EFVY}3~6yTxt#o5J$}ratWn!__x90aEqfnd zopnfeQaA3!+pu+Q19PMSeFC#a?yw-XrdWvH3JBZ~!FUDBu5FE-TU(b@=`U+|`icwl zpbKjy8yqhxididwxRExx!|V>v)uoxG<#5Y*wf57(@D8T~Pp98AQY}L+$2I~nH%X4! z*SjINUhl?kxK%%Ip_?ppi4~E;66>Ds$7m+D?W?>gEr|_;#W~l(FZsoK3)Bl+u`~o` zAU{s~9fP0~7tGKfrv3j&+DvYAexlpweyQ+CXZi-#OwJSvkL%o#(6YOw9R5w!+Vu#N zy&}@SEu3TbT%RXPV~*LL(HYu8`}B}szWBE4lo_NUa%K5+W;_iUDQM8TKlB|}GA6n) zwi3w8YMlp0q5X!kD6Tht&zG5ui!>W4? zDO}f^u8JnZh7@m(f7DCZWrR$6D&oLC~K zE}f^vZ%$PmyoRJpG=6p7DJpamxs(cg|8P!*V^!s&^OJX8hsvt2>IehKPm_g7USM}N zqX~QZtiqIm_6x~m!s>GEaqMlKUob^U!33N_29ZbH%H)e5x~iwp!E$!8ywRX(^e0 zbC|y4K|&bWZBL>neA~+8*|3G2Vwcrk9V*E6w&N1IIoYmYqa$9CljU zhjjNkvobwx)`Pf|8P^lG2dI zkkx+xI6n_>&yfEPba({*n;`rD34z|Y`vHOXaQ^?tKoyl?Hc%B!RhXt4^nXKUdA_in PF#xo_IqKDKZZZD@Be@1l literal 0 HcmV?d00001 diff --git a/ui/src/components/claudekit-badge.tsx b/ui/src/components/claudekit-badge.tsx new file mode 100644 index 00000000..a8c4308b --- /dev/null +++ b/ui/src/components/claudekit-badge.tsx @@ -0,0 +1,49 @@ +/** + * ClaudeKit Badge Button + * + * "Powered by ClaudeKit" badge for navbar, inspired by landing page design. + * Compact version optimized for header placement. + */ + +import { cn } from '@/lib/utils'; + +const CLAUDEKIT_URL = 'https://claudekit.cc?ref=HMNKXOHN'; + +export function ClaudeKitBadge() { + return ( + + ClaudeKit + + + Powered by + + + ClaudeKit + + + + ); +} diff --git a/ui/src/components/layout.tsx b/ui/src/components/layout.tsx index e7f841d7..a98494cc 100644 --- a/ui/src/components/layout.tsx +++ b/ui/src/components/layout.tsx @@ -9,6 +9,8 @@ import { DocsLink } from '@/components/docs-link'; import { ConnectionIndicator } from '@/components/connection-indicator'; import { LocalhostDisclaimer } from '@/components/localhost-disclaimer'; import { Skeleton } from '@/components/ui/skeleton'; +import { ClaudeKitBadge } from '@/components/claudekit-badge'; +import { SponsorButton } from '@/components/sponsor-button'; function PageLoader() { return ( @@ -25,7 +27,10 @@ export function Layout() {
-
CCS Config
+
+ + +
diff --git a/ui/src/components/sponsor-button.tsx b/ui/src/components/sponsor-button.tsx new file mode 100644 index 00000000..382d9afb --- /dev/null +++ b/ui/src/components/sponsor-button.tsx @@ -0,0 +1,46 @@ +/** + * Sponsor Button + * + * GitHub Sponsors button for navbar. + * Heart icon with hover animation. + */ + +import { Heart } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const SPONSOR_URL = 'https://github.com/sponsors/kaitranntt'; + +export function SponsorButton() { + return ( + + + + Sponsor + + + ); +} From 581be3ec124d6850576230209a20b3c1fa7f5e13 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Dec 2025 23:58:43 +0000 Subject: [PATCH 15/15] chore(release): 6.1.0-dev.6 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index cdcf3aeb..02e02851 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.1.0-dev.5 +6.1.0-dev.6 diff --git a/package.json b/package.json index 5a3f2dcc..43047260 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.1.0-dev.5", + "version": "6.1.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",