|
1 | | -import React, { useEffect, useMemo } from 'react' |
| 1 | +import { UserState, getUserState } from '@codebuff/common/old-constants' |
| 2 | +import { useQuery } from '@tanstack/react-query' |
| 3 | +import React, { useEffect, useRef, useState } from 'react' |
2 | 4 |
|
3 | | -import { Button } from './button' |
4 | | -import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' |
| 5 | +import { BannerWrapper } from './banner-wrapper' |
5 | 6 | import { useTheme } from '../hooks/use-theme' |
6 | | -import { useUsageQuery } from '../hooks/use-usage-query' |
| 7 | +import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query' |
7 | 8 | import { useChatStore } from '../state/chat-store' |
8 | | -import { BORDER_CHARS } from '../utils/ui-constants' |
| 9 | +import { getAuthToken } from '../utils/auth' |
9 | 10 |
|
10 | | -// Credit level thresholds for banner color |
11 | 11 | const HIGH_CREDITS_THRESHOLD = 1000 |
12 | 12 | const MEDIUM_CREDITS_THRESHOLD = 100 |
13 | 13 |
|
| 14 | +const MANUAL_SHOW_TIMEOUT = 60 * 1000 // 1 minute |
| 15 | +const AUTO_SHOW_TIMEOUT = 5 * 60 * 1000 // 5 minutes |
| 16 | + |
14 | 17 | export const UsageBanner = () => { |
15 | | - const { terminalWidth } = useTerminalDimensions() |
16 | 18 | const theme = useTheme() |
17 | | - const isUsageVisible = useChatStore((state) => state.isUsageVisible) |
18 | 19 | const sessionCreditsUsed = useChatStore((state) => state.sessionCreditsUsed) |
19 | | - const setIsUsageVisible = useChatStore((state) => state.setIsUsageVisible) |
20 | | - const [isCloseHovered, setIsCloseHovered] = React.useState(false) |
21 | | - |
22 | | - // Fetch usage data when banner is visible |
23 | | - const { data: apiData } = useUsageQuery({ enabled: isUsageVisible }) |
24 | | - |
25 | | - // Transform API data to usage data format |
26 | | - const usageData = apiData |
27 | | - ? { |
28 | | - sessionUsage: sessionCreditsUsed, |
29 | | - remainingBalance: apiData.remainingBalance, |
30 | | - nextQuotaReset: apiData.next_quota_reset, |
31 | | - } |
32 | | - : null |
33 | | - |
34 | | - // Auto-hide banner after 60 seconds |
| 20 | + const isChainInProgress = useChatStore((state) => state.isChainInProgress) |
| 21 | + const setInputMode = useChatStore((state) => state.setInputMode) |
| 22 | + |
| 23 | + const [isAutoShown, setIsAutoShown] = useState(false) |
| 24 | + const lastWarnedStateRef = useRef<UserState | null>(null) |
| 25 | + |
| 26 | + const { data: apiData } = useUsageQuery({ enabled: true }) |
| 27 | + |
| 28 | + const { data: cachedUsageData } = useQuery<{ |
| 29 | + type: 'usage-response' |
| 30 | + usage: number |
| 31 | + remainingBalance: number | null |
| 32 | + balanceBreakdown?: { free: number; paid: number } |
| 33 | + next_quota_reset: string | null |
| 34 | + }>({ |
| 35 | + queryKey: usageQueryKeys.current(), |
| 36 | + enabled: false, |
| 37 | + }) |
| 38 | + |
| 39 | + // Credit warning monitoring logic |
35 | 40 | useEffect(() => { |
36 | | - if (isUsageVisible) { |
37 | | - const timer = setTimeout(() => { |
38 | | - setIsUsageVisible(false) |
39 | | - }, 60000) |
40 | | - return () => clearTimeout(timer) |
41 | | - } else { |
42 | | - // Reset hover state when banner closes |
43 | | - setIsCloseHovered(false) |
44 | | - } |
45 | | - return undefined |
46 | | - }, [isUsageVisible, setIsUsageVisible]) |
| 41 | + if (isChainInProgress) return |
| 42 | + const authToken = getAuthToken() |
| 43 | + if (!authToken) return |
| 44 | + if (!cachedUsageData || cachedUsageData.remainingBalance === null) return |
47 | 45 |
|
48 | | - // Memoize the banner text computation |
49 | | - const text = useMemo(() => { |
50 | | - if (!usageData) return '' |
| 46 | + const credits = cachedUsageData.remainingBalance |
| 47 | + const userState = getUserState(true, credits) |
51 | 48 |
|
52 | | - let result = `Session usage: ${usageData.sessionUsage.toLocaleString()}` |
53 | | - |
54 | | - if (usageData.remainingBalance !== null) { |
55 | | - result += `. Credits remaining: ${usageData.remainingBalance.toLocaleString()}` |
| 49 | + if (userState === UserState.GOOD_STANDING) { |
| 50 | + lastWarnedStateRef.current = null |
| 51 | + return |
56 | 52 | } |
57 | 53 |
|
58 | | - if (usageData.nextQuotaReset) { |
59 | | - const resetDate = new Date(usageData.nextQuotaReset) |
60 | | - const today = new Date() |
61 | | - const isToday = resetDate.toDateString() === today.toDateString() |
62 | | - |
63 | | - // Format date without slashes to prevent mid-date line breaks |
64 | | - const dateDisplay = isToday |
65 | | - ? resetDate.toLocaleString('en-US', { |
66 | | - month: 'short', |
67 | | - day: 'numeric', |
68 | | - year: 'numeric', |
69 | | - hour: 'numeric', |
70 | | - minute: '2-digit', |
71 | | - }) |
72 | | - : resetDate.toLocaleDateString('en-US', { |
73 | | - month: 'short', |
74 | | - day: 'numeric', |
75 | | - year: 'numeric', |
76 | | - }) |
77 | | - |
78 | | - result += `. Free credits renew ${dateDisplay}` |
| 54 | + if ( |
| 55 | + lastWarnedStateRef.current !== userState && |
| 56 | + (userState === UserState.ATTENTION_NEEDED || |
| 57 | + userState === UserState.CRITICAL || |
| 58 | + userState === UserState.DEPLETED) |
| 59 | + ) { |
| 60 | + lastWarnedStateRef.current = userState |
| 61 | + setIsAutoShown(true) |
79 | 62 | } |
| 63 | + }, [isChainInProgress, cachedUsageData]) |
80 | 64 |
|
81 | | - return result |
82 | | - }, [usageData]) |
83 | | - |
84 | | - const bannerColor = useMemo(() => { |
85 | | - // Default color |
86 | | - if (!usageData || usageData.remainingBalance === null) { |
87 | | - return theme.warning |
88 | | - } |
89 | | - |
90 | | - const balance = usageData.remainingBalance |
91 | | - |
92 | | - if (balance >= HIGH_CREDITS_THRESHOLD) { |
93 | | - return theme.success |
94 | | - } |
95 | | - |
96 | | - if (balance >= MEDIUM_CREDITS_THRESHOLD) { |
97 | | - return theme.warning |
98 | | - } |
99 | | - |
100 | | - return theme.error |
101 | | - }, [usageData, theme]) |
102 | | - |
103 | | - if (!isUsageVisible || !usageData) return null |
| 65 | + // Auto-hide effect |
| 66 | + useEffect(() => { |
| 67 | + const timeout = isAutoShown ? AUTO_SHOW_TIMEOUT : MANUAL_SHOW_TIMEOUT |
| 68 | + const timer = setTimeout(() => { |
| 69 | + setInputMode('default') |
| 70 | + setIsAutoShown(false) |
| 71 | + }, timeout) |
| 72 | + return () => clearTimeout(timer) |
| 73 | + }, [isAutoShown, setInputMode]) |
| 74 | + |
| 75 | + const activeData = apiData || cachedUsageData |
| 76 | + if (!activeData) return null |
| 77 | + |
| 78 | + const balance = activeData.remainingBalance |
| 79 | + let color = theme.warning |
| 80 | + |
| 81 | + if (balance === null) { |
| 82 | + color = theme.warning |
| 83 | + } else if (balance >= HIGH_CREDITS_THRESHOLD) { |
| 84 | + color = theme.success |
| 85 | + } else if (balance >= MEDIUM_CREDITS_THRESHOLD) { |
| 86 | + color = theme.warning |
| 87 | + } else { |
| 88 | + color = theme.error |
| 89 | + } |
| 90 | + |
| 91 | + let text = `Session usage: ${sessionCreditsUsed.toLocaleString()}` |
| 92 | + |
| 93 | + if (activeData.remainingBalance !== null) { |
| 94 | + text += `. Credits remaining: ${activeData.remainingBalance.toLocaleString()}` |
| 95 | + } |
| 96 | + |
| 97 | + if (activeData.next_quota_reset) { |
| 98 | + const resetDate = new Date(activeData.next_quota_reset) |
| 99 | + const today = new Date() |
| 100 | + const isToday = resetDate.toDateString() === today.toDateString() |
| 101 | + |
| 102 | + const dateDisplay = isToday |
| 103 | + ? resetDate.toLocaleString('en-US', { |
| 104 | + month: 'short', |
| 105 | + day: 'numeric', |
| 106 | + year: 'numeric', |
| 107 | + hour: 'numeric', |
| 108 | + minute: '2-digit', |
| 109 | + }) |
| 110 | + : resetDate.toLocaleDateString('en-US', { |
| 111 | + month: 'short', |
| 112 | + day: 'numeric', |
| 113 | + year: 'numeric', |
| 114 | + }) |
| 115 | + |
| 116 | + text += `. Free credits renew ${dateDisplay}` |
| 117 | + } |
104 | 118 |
|
105 | 119 | return ( |
106 | | - <box |
107 | | - key={terminalWidth} |
108 | | - style={{ |
109 | | - width: '100%', |
110 | | - borderStyle: 'single', |
111 | | - borderColor: bannerColor, |
112 | | - flexDirection: 'row', |
113 | | - justifyContent: 'space-between', |
114 | | - paddingLeft: 1, |
115 | | - paddingRight: 1, |
116 | | - marginTop: 0, |
117 | | - marginBottom: 0, |
118 | | - }} |
119 | | - border={['bottom', 'left', 'right']} |
120 | | - customBorderChars={BORDER_CHARS} |
121 | | - > |
122 | | - <text |
123 | | - style={{ |
124 | | - fg: bannerColor, |
125 | | - wrapMode: 'word', |
126 | | - flexShrink: 1, |
127 | | - marginRight: 3, |
128 | | - }} |
129 | | - > |
130 | | - {text} |
131 | | - </text> |
132 | | - <Button |
133 | | - onClick={() => setIsUsageVisible(false)} |
134 | | - onMouseOver={() => setIsCloseHovered(true)} |
135 | | - onMouseOut={() => setIsCloseHovered(false)} |
136 | | - > |
137 | | - <text style={{ fg: isCloseHovered ? theme.error : theme.muted }}>x</text> |
138 | | - </Button> |
139 | | - </box> |
| 120 | + <BannerWrapper |
| 121 | + color={color} |
| 122 | + text={text} |
| 123 | + onClose={() => setInputMode('default')} |
| 124 | + /> |
140 | 125 | ) |
141 | 126 | } |
0 commit comments