diff --git a/VERSION b/VERSION index dfda3e0b..02e02851 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.1.0 +6.1.0-dev.6 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/package.json b/package.json index cc697cab..43047260 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.1.0", + "version": "6.1.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", 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', }, }; diff --git a/ui/public/logos/claudekit-logo.png b/ui/public/logos/claudekit-logo.png new file mode 100644 index 00000000..22c9fecc Binary files /dev/null and b/ui/public/logos/claudekit-logo.png differ diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index 05fa86d4..01c3f766 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -12,14 +12,30 @@ 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'; +// 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; @@ -61,20 +77,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 +171,7 @@ function ConnectionTimeline({ {/* Events */}
- {events.slice(0, 8).map((event) => { + {events.map((event) => { const statusColor = event.status === 'success' ? STATUS_COLORS.success @@ -186,15 +223,6 @@ function ConnectionTimeline({ ); })}
- - {/* Show more indicator */} - {events.length > 8 && ( -
- - +{events.length - 8} more events - -
- )} @@ -206,38 +234,133 @@ 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 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 +419,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 +490,93 @@ 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]); + + // 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 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; + + // 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( @@ -361,6 +586,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 +598,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]: { @@ -395,28 +625,70 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { return (
- {/* Back button */} - {onBack && ( - - )} + {/* Header: Back button + Reset Layout */} +
+ {onBack ? ( + + ) : ( +
+ )} +
+ - {/* Main visualization area - 3 column layout: Left Accounts | Provider | Right Accounts + Timeline */} -
+ {Object.keys(dragOffsets).length > 0 && ( + + )} +
+
+ + {/* Main visualization area - Multi-zone layout */} +
{/* Flow visualization section */} -
+
{/* SVG Canvas (Background) */} - + @@ -428,6 +700,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 ( @@ -435,9 +709,9 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { - {/* 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; const isDragging = draggingId === account.id; const offset = getOffset(account.id); @@ -662,8 +760,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
!isDragging && setSelectedAccount(account)} + data-zone="top" onMouseEnter={() => setHoveredAccount(originalIndex)} onMouseLeave={() => setHoveredAccount(null)} onPointerDown={(e) => handlePointerDown(account.id, e)} @@ -671,27 +768,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-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', + '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 */} - -
- + +
-
-
-
- {account.failureCount > 0 && ( -
- )} -
- - {total.toLocaleString()} reqs - -
- {/* 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 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 */} -
- -
-
- - {/* 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)} -
-
-
- )} +
+
+
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/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/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/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/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/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 + + + ); +} 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/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)} 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'], }, }, },