better-codex 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/backend/README.md +43 -2
- package/apps/backend/src/core/app-server.ts +68 -2
- package/apps/backend/src/core/jsonrpc.ts +13 -0
- package/apps/backend/src/server.ts +156 -1
- package/apps/backend/src/services/codex-config.ts +561 -0
- package/apps/backend/src/thread-activity/service.ts +47 -0
- package/apps/web/README.md +18 -2
- package/apps/web/src/components/layout/codex-settings.tsx +1208 -0
- package/apps/web/src/components/layout/session-view.tsx +203 -8
- package/apps/web/src/components/layout/settings-dialog.tsx +9 -1
- package/apps/web/src/components/layout/virtualized-message-list.tsx +594 -90
- package/apps/web/src/components/session-view/rate-limit-banner.tsx +178 -0
- package/apps/web/src/components/session-view/session-dialogs.tsx +93 -2
- package/apps/web/src/components/session-view/session-header.tsx +21 -2
- package/apps/web/src/components/session-view/thread-account-switcher.tsx +191 -0
- package/apps/web/src/components/ui/icons.tsx +12 -0
- package/apps/web/src/hooks/use-hub-connection.ts +59 -19
- package/apps/web/src/hooks/use-thread-history.ts +94 -5
- package/apps/web/src/services/hub-client.ts +98 -1
- package/apps/web/src/store/index.ts +36 -1
- package/apps/web/src/types/index.ts +25 -0
- package/apps/web/src/utils/item-format.ts +55 -9
- package/apps/web/src/utils/slash-commands.ts +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { Icons, Button } from '../ui'
|
|
3
|
+
import type { Account } from '../../types'
|
|
4
|
+
|
|
5
|
+
interface RateLimitBannerProps {
|
|
6
|
+
visible: boolean
|
|
7
|
+
currentAccount?: Account
|
|
8
|
+
availableAccounts: Account[]
|
|
9
|
+
errorMessage?: string
|
|
10
|
+
onSwitchAccount: (accountId: string) => void
|
|
11
|
+
onDismiss: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const isRateLimitError = (message: string): boolean => {
|
|
15
|
+
const lowerMessage = message.toLowerCase()
|
|
16
|
+
return (
|
|
17
|
+
lowerMessage.includes('rate limit') ||
|
|
18
|
+
lowerMessage.includes('usage limit') ||
|
|
19
|
+
lowerMessage.includes('quota exceeded') ||
|
|
20
|
+
lowerMessage.includes('too many requests') ||
|
|
21
|
+
lowerMessage.includes('request limit') ||
|
|
22
|
+
lowerMessage.includes('hit your usage limit') ||
|
|
23
|
+
lowerMessage.includes('limit reached') ||
|
|
24
|
+
lowerMessage.includes('try again') && lowerMessage.includes('limit')
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const RateLimitBanner = ({
|
|
29
|
+
visible,
|
|
30
|
+
currentAccount,
|
|
31
|
+
availableAccounts,
|
|
32
|
+
errorMessage,
|
|
33
|
+
onSwitchAccount,
|
|
34
|
+
onDismiss,
|
|
35
|
+
}: RateLimitBannerProps) => {
|
|
36
|
+
const [selectedAccountId, setSelectedAccountId] = useState<string | null>(null)
|
|
37
|
+
|
|
38
|
+
// Auto-select first available account
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (availableAccounts.length > 0 && !selectedAccountId) {
|
|
41
|
+
// Prefer accounts with lower usage
|
|
42
|
+
const sorted = [...availableAccounts].sort((a, b) => {
|
|
43
|
+
const aUsage = a.usage?.primary?.usedPercent ?? 0
|
|
44
|
+
const bUsage = b.usage?.primary?.usedPercent ?? 0
|
|
45
|
+
return aUsage - bUsage
|
|
46
|
+
})
|
|
47
|
+
setSelectedAccountId(sorted[0]?.id ?? null)
|
|
48
|
+
}
|
|
49
|
+
}, [availableAccounts, selectedAccountId])
|
|
50
|
+
|
|
51
|
+
if (!visible) {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const handleSwitch = () => {
|
|
56
|
+
if (selectedAccountId) {
|
|
57
|
+
onSwitchAccount(selectedAccountId)
|
|
58
|
+
onDismiss()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const extractResetTime = (message: string): string | null => {
|
|
63
|
+
const patterns = [
|
|
64
|
+
/try again (?:at|after) ([^.]+)/i,
|
|
65
|
+
/resets? (?:at|in) ([^.]+)/i,
|
|
66
|
+
/available (?:at|after) ([^.]+)/i,
|
|
67
|
+
]
|
|
68
|
+
for (const pattern of patterns) {
|
|
69
|
+
const match = message.match(pattern)
|
|
70
|
+
if (match) {
|
|
71
|
+
return match[1].trim()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const resetTime = errorMessage ? extractResetTime(errorMessage) : null
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="px-4 py-3 border-b border-border bg-bg-secondary/70">
|
|
81
|
+
<div className="bg-bg-tertiary border border-border rounded-xl p-4">
|
|
82
|
+
<div className="flex items-start gap-3">
|
|
83
|
+
<div className="w-9 h-9 rounded-lg bg-bg-primary border border-border flex items-center justify-center">
|
|
84
|
+
<Icons.Warning className="w-4 h-4 text-yellow-500" />
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div className="flex-1 min-w-0">
|
|
88
|
+
<h3 className="text-sm font-semibold text-text-primary">
|
|
89
|
+
Usage Limit Reached
|
|
90
|
+
</h3>
|
|
91
|
+
<p className="text-xs text-text-muted mt-1">
|
|
92
|
+
{currentAccount?.name || 'Current account'} has hit its usage limit.
|
|
93
|
+
{resetTime && (
|
|
94
|
+
<span className="block mt-0.5">
|
|
95
|
+
Resets: <span className="text-text-secondary">{resetTime}</span>
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
</p>
|
|
99
|
+
|
|
100
|
+
{availableAccounts.length > 0 ? (
|
|
101
|
+
<div className="mt-3">
|
|
102
|
+
<p className="text-xs text-text-secondary mb-2">
|
|
103
|
+
Continue with another account:
|
|
104
|
+
</p>
|
|
105
|
+
<div className="flex flex-wrap gap-2">
|
|
106
|
+
{availableAccounts.map((account) => (
|
|
107
|
+
<button
|
|
108
|
+
key={account.id}
|
|
109
|
+
onClick={() => setSelectedAccountId(account.id)}
|
|
110
|
+
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all ${
|
|
111
|
+
selectedAccountId === account.id
|
|
112
|
+
? 'bg-accent-green-soft border-accent-green/50 text-accent-green'
|
|
113
|
+
: 'bg-bg-secondary border-border hover:border-text-muted text-text-secondary'
|
|
114
|
+
}`}
|
|
115
|
+
>
|
|
116
|
+
<div className={`w-2 h-2 rounded-full ${
|
|
117
|
+
account.status === 'online' ? 'bg-accent-green' : 'bg-text-muted'
|
|
118
|
+
}`} />
|
|
119
|
+
<span className="text-xs font-medium">{account.name}</span>
|
|
120
|
+
{account.usage?.primary && (
|
|
121
|
+
<span className={`text-[10px] ${
|
|
122
|
+
account.usage.primary.usedPercent >= 80 ? 'text-yellow-500' :
|
|
123
|
+
account.usage.primary.usedPercent >= 60 ? 'text-text-muted' :
|
|
124
|
+
'text-accent-green'
|
|
125
|
+
}`}>
|
|
126
|
+
{Math.round(account.usage.primary.usedPercent)}%
|
|
127
|
+
</span>
|
|
128
|
+
)}
|
|
129
|
+
</button>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div className="flex items-center gap-2 mt-3">
|
|
134
|
+
<Button
|
|
135
|
+
variant="primary"
|
|
136
|
+
size="sm"
|
|
137
|
+
onClick={handleSwitch}
|
|
138
|
+
disabled={!selectedAccountId}
|
|
139
|
+
>
|
|
140
|
+
<Icons.ArrowRight className="w-3.5 h-3.5" />
|
|
141
|
+
Switch Account
|
|
142
|
+
</Button>
|
|
143
|
+
<Button
|
|
144
|
+
variant="ghost"
|
|
145
|
+
size="sm"
|
|
146
|
+
onClick={onDismiss}
|
|
147
|
+
>
|
|
148
|
+
Dismiss
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
) : (
|
|
153
|
+
<div className="mt-3 flex items-center gap-2">
|
|
154
|
+
<p className="text-xs text-text-muted">
|
|
155
|
+
No other accounts available. Add more accounts in the sidebar or wait for the limit to reset.
|
|
156
|
+
</p>
|
|
157
|
+
<Button
|
|
158
|
+
variant="ghost"
|
|
159
|
+
size="sm"
|
|
160
|
+
onClick={onDismiss}
|
|
161
|
+
>
|
|
162
|
+
Dismiss
|
|
163
|
+
</Button>
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<button
|
|
169
|
+
onClick={onDismiss}
|
|
170
|
+
className="shrink-0 p-1 rounded hover:bg-bg-hover transition-colors"
|
|
171
|
+
>
|
|
172
|
+
<Icons.X className="w-4 h-4 text-text-muted" />
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ApprovalPolicy, ReasoningEffort, ReasoningSummary } from '../../types'
|
|
2
|
-
import { AlertDialog, Button, CopyDialog, Dialog, PromptDialog, Select, type SelectOption } from '../ui'
|
|
1
|
+
import type { Account, ApprovalPolicy, ReasoningEffort, ReasoningSummary } from '../../types'
|
|
2
|
+
import { AlertDialog, Button, CopyDialog, Dialog, Icons, PromptDialog, Select, type SelectOption } from '../ui'
|
|
3
3
|
|
|
4
4
|
interface SessionDialogsProps {
|
|
5
5
|
showModelDialog: boolean
|
|
@@ -32,6 +32,11 @@ interface SessionDialogsProps {
|
|
|
32
32
|
onCloseResumeDialog: () => void
|
|
33
33
|
resumeCandidates: Array<{ id: string; title: string; preview: string }>
|
|
34
34
|
onResumeThread: (threadId: string) => void
|
|
35
|
+
showAccountSwitchDialog: boolean
|
|
36
|
+
onCloseAccountSwitchDialog: () => void
|
|
37
|
+
currentAccount?: Account
|
|
38
|
+
switchableAccounts: Account[]
|
|
39
|
+
onSwitchAccount: (accountId: string) => void
|
|
35
40
|
showFeedbackDialog: boolean
|
|
36
41
|
onCloseFeedbackDialog: () => void
|
|
37
42
|
feedbackCategory: string
|
|
@@ -81,6 +86,11 @@ export const SessionDialogs = ({
|
|
|
81
86
|
onCloseResumeDialog,
|
|
82
87
|
resumeCandidates,
|
|
83
88
|
onResumeThread,
|
|
89
|
+
showAccountSwitchDialog,
|
|
90
|
+
onCloseAccountSwitchDialog,
|
|
91
|
+
currentAccount,
|
|
92
|
+
switchableAccounts,
|
|
93
|
+
onSwitchAccount,
|
|
84
94
|
showFeedbackDialog,
|
|
85
95
|
onCloseFeedbackDialog,
|
|
86
96
|
feedbackCategory,
|
|
@@ -212,6 +222,87 @@ export const SessionDialogs = ({
|
|
|
212
222
|
</div>
|
|
213
223
|
</Dialog>
|
|
214
224
|
|
|
225
|
+
<Dialog open={showAccountSwitchDialog} onClose={onCloseAccountSwitchDialog} title="Switch Account">
|
|
226
|
+
<div className="space-y-3">
|
|
227
|
+
<p className="text-xs text-text-muted">
|
|
228
|
+
Continue this thread with a different account. The conversation history will be preserved.
|
|
229
|
+
</p>
|
|
230
|
+
|
|
231
|
+
{currentAccount && (
|
|
232
|
+
<div className="p-3 rounded-lg bg-bg-tertiary border border-border">
|
|
233
|
+
<div className="flex items-center gap-2">
|
|
234
|
+
<div className={`w-2 h-2 rounded-full ${
|
|
235
|
+
currentAccount.status === 'online' ? 'bg-accent-green' : 'bg-text-muted'
|
|
236
|
+
}`} />
|
|
237
|
+
<span className="text-xs font-medium text-text-primary">{currentAccount.name}</span>
|
|
238
|
+
<span className="text-[10px] text-text-muted ml-auto">current</span>
|
|
239
|
+
</div>
|
|
240
|
+
{currentAccount.usage?.primary && (
|
|
241
|
+
<div className="mt-2 flex items-center gap-2">
|
|
242
|
+
<div className="flex-1 h-1 bg-bg-primary rounded-full overflow-hidden">
|
|
243
|
+
<div
|
|
244
|
+
className={`h-full rounded-full transition-all ${
|
|
245
|
+
currentAccount.usage.primary.usedPercent >= 90 ? 'bg-accent-red' :
|
|
246
|
+
currentAccount.usage.primary.usedPercent >= 80 ? 'bg-accent-orange' :
|
|
247
|
+
'bg-accent-green'
|
|
248
|
+
}`}
|
|
249
|
+
style={{ width: `${Math.min(100, currentAccount.usage.primary.usedPercent)}%` }}
|
|
250
|
+
/>
|
|
251
|
+
</div>
|
|
252
|
+
<span className="text-[10px] text-text-muted">
|
|
253
|
+
{Math.round(currentAccount.usage.primary.usedPercent)}% used
|
|
254
|
+
</span>
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
|
261
|
+
{switchableAccounts.length === 0 && (
|
|
262
|
+
<div className="text-xs text-text-muted py-4 text-center">
|
|
263
|
+
No other authenticated accounts available.
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
{switchableAccounts.map((account) => (
|
|
267
|
+
<button
|
|
268
|
+
key={account.id}
|
|
269
|
+
type="button"
|
|
270
|
+
onClick={() => {
|
|
271
|
+
onSwitchAccount(account.id)
|
|
272
|
+
onCloseAccountSwitchDialog()
|
|
273
|
+
}}
|
|
274
|
+
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border border-border bg-bg-tertiary hover:bg-bg-hover hover:border-text-muted/40 transition-colors"
|
|
275
|
+
>
|
|
276
|
+
<div className={`w-2 h-2 rounded-full ${
|
|
277
|
+
account.status === 'online' ? 'bg-accent-green' : 'bg-text-muted'
|
|
278
|
+
}`} />
|
|
279
|
+
<div className="flex-1 min-w-0 text-left">
|
|
280
|
+
<div className="text-xs font-medium text-text-primary truncate">{account.name}</div>
|
|
281
|
+
{account.usage?.primary && (
|
|
282
|
+
<div className="mt-1 flex items-center gap-2">
|
|
283
|
+
<div className="flex-1 h-1 bg-bg-primary rounded-full overflow-hidden">
|
|
284
|
+
<div
|
|
285
|
+
className={`h-full rounded-full transition-all ${
|
|
286
|
+
account.usage.primary.usedPercent >= 90 ? 'bg-accent-red' :
|
|
287
|
+
account.usage.primary.usedPercent >= 80 ? 'bg-accent-orange' :
|
|
288
|
+
'bg-accent-green'
|
|
289
|
+
}`}
|
|
290
|
+
style={{ width: `${Math.min(100, account.usage.primary.usedPercent)}%` }}
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
<span className="text-[10px] text-text-muted">
|
|
294
|
+
{Math.round(account.usage.primary.usedPercent)}%
|
|
295
|
+
</span>
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
<Icons.ArrowRight className="w-4 h-4 text-text-muted shrink-0" />
|
|
300
|
+
</button>
|
|
301
|
+
))}
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</Dialog>
|
|
305
|
+
|
|
215
306
|
<Dialog open={showFeedbackDialog} onClose={onCloseFeedbackDialog} title="Send Feedback">
|
|
216
307
|
<div className="space-y-3">
|
|
217
308
|
<Select
|
|
@@ -1,30 +1,49 @@
|
|
|
1
1
|
import { IconButton, Icons } from '../ui'
|
|
2
|
+
import { ThreadAccountSwitcher } from './thread-account-switcher'
|
|
3
|
+
import type { Account } from '../../types'
|
|
2
4
|
|
|
3
5
|
interface SessionHeaderProps {
|
|
4
6
|
title: string
|
|
7
|
+
accountId?: string
|
|
5
8
|
accountName?: string
|
|
9
|
+
accounts?: Account[]
|
|
6
10
|
model?: string
|
|
7
11
|
status?: string
|
|
8
12
|
canInteract: boolean
|
|
9
13
|
onArchive: () => void
|
|
14
|
+
onSwitchAccount?: (accountId: string) => void
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
export const SessionHeader = ({
|
|
13
18
|
title,
|
|
19
|
+
accountId,
|
|
14
20
|
accountName,
|
|
21
|
+
accounts = [],
|
|
15
22
|
model,
|
|
16
23
|
status,
|
|
17
24
|
canInteract,
|
|
18
25
|
onArchive,
|
|
26
|
+
onSwitchAccount,
|
|
19
27
|
}: SessionHeaderProps) => {
|
|
20
28
|
const isActive = status === 'active'
|
|
29
|
+
const showAccountSwitcher = accounts.length > 1 && accountId && onSwitchAccount
|
|
21
30
|
|
|
22
31
|
return (
|
|
23
32
|
<header className="hidden md:flex px-4 py-3 border-b border-border items-center justify-between shrink-0 gap-4">
|
|
24
33
|
<div className="min-w-0 flex-1 overflow-hidden">
|
|
25
34
|
<h2 className="text-sm font-semibold text-text-primary truncate">{title}</h2>
|
|
26
|
-
<div className="flex items-center gap-
|
|
27
|
-
|
|
35
|
+
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-text-muted">
|
|
36
|
+
{showAccountSwitcher ? (
|
|
37
|
+
<ThreadAccountSwitcher
|
|
38
|
+
currentAccountId={accountId}
|
|
39
|
+
currentAccountName={accountName}
|
|
40
|
+
accounts={accounts}
|
|
41
|
+
disabled={isActive}
|
|
42
|
+
onSwitch={onSwitchAccount}
|
|
43
|
+
/>
|
|
44
|
+
) : (
|
|
45
|
+
<span className="truncate max-w-[100px] px-2 py-1">{accountName || 'Unknown account'}</span>
|
|
46
|
+
)}
|
|
28
47
|
<span>·</span>
|
|
29
48
|
<span>{model || 'unknown'}</span>
|
|
30
49
|
<span>·</span>
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { Icons } from '../ui'
|
|
4
|
+
import type { Account } from '../../types'
|
|
5
|
+
|
|
6
|
+
interface ThreadAccountSwitcherProps {
|
|
7
|
+
currentAccountId: string
|
|
8
|
+
currentAccountName?: string
|
|
9
|
+
accounts: Account[]
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
onSwitch: (accountId: string) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const ThreadAccountSwitcher = ({
|
|
15
|
+
currentAccountId,
|
|
16
|
+
currentAccountName,
|
|
17
|
+
accounts,
|
|
18
|
+
disabled,
|
|
19
|
+
onSwitch,
|
|
20
|
+
}: ThreadAccountSwitcherProps) => {
|
|
21
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
22
|
+
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 })
|
|
23
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
24
|
+
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
25
|
+
|
|
26
|
+
// Filter to only show other accounts that are online
|
|
27
|
+
const availableAccounts = accounts.filter(
|
|
28
|
+
(account) => account.id !== currentAccountId && account.status === 'online'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// Update dropdown position when opening
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (isOpen && buttonRef.current) {
|
|
34
|
+
const rect = buttonRef.current.getBoundingClientRect()
|
|
35
|
+
setDropdownPosition({
|
|
36
|
+
top: rect.bottom + 4,
|
|
37
|
+
left: rect.left,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}, [isOpen])
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
44
|
+
const target = event.target as Node
|
|
45
|
+
if (
|
|
46
|
+
buttonRef.current && !buttonRef.current.contains(target) &&
|
|
47
|
+
dropdownRef.current && !dropdownRef.current.contains(target)
|
|
48
|
+
) {
|
|
49
|
+
setIsOpen(false)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (isOpen) {
|
|
54
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
55
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
56
|
+
}
|
|
57
|
+
}, [isOpen])
|
|
58
|
+
|
|
59
|
+
const handleSwitch = (accountId: string) => {
|
|
60
|
+
onSwitch(accountId)
|
|
61
|
+
setIsOpen(false)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const currentAccount = accounts.find((a) => a.id === currentAccountId)
|
|
65
|
+
const hasRateLimitWarning = currentAccount?.usage?.primary?.usedPercent
|
|
66
|
+
? currentAccount.usage.primary.usedPercent >= 80
|
|
67
|
+
: false
|
|
68
|
+
|
|
69
|
+
const dropdownContent = isOpen && availableAccounts.length > 0 && (
|
|
70
|
+
<div
|
|
71
|
+
ref={dropdownRef}
|
|
72
|
+
className="fixed min-w-[200px] bg-bg-elevated border border-border rounded-lg shadow-lg py-1 z-[9999]"
|
|
73
|
+
style={{ top: dropdownPosition.top, left: dropdownPosition.left }}
|
|
74
|
+
>
|
|
75
|
+
<div className="px-3 py-2 border-b border-border">
|
|
76
|
+
<p className="text-[10px] text-text-muted font-medium uppercase tracking-wide">
|
|
77
|
+
Switch Account
|
|
78
|
+
</p>
|
|
79
|
+
<p className="text-[10px] text-text-muted mt-0.5">
|
|
80
|
+
Continue this thread with another account
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* Current account */}
|
|
85
|
+
<div className="px-3 py-2 bg-bg-secondary/50">
|
|
86
|
+
<div className="flex items-center gap-2">
|
|
87
|
+
<div className={`w-2 h-2 rounded-full ${
|
|
88
|
+
currentAccount?.status === 'online' ? 'bg-accent-green' : 'bg-text-muted'
|
|
89
|
+
}`} />
|
|
90
|
+
<span className="text-xs text-text-primary font-medium">
|
|
91
|
+
{currentAccountName}
|
|
92
|
+
</span>
|
|
93
|
+
<span className="text-[10px] text-text-muted ml-auto">current</span>
|
|
94
|
+
</div>
|
|
95
|
+
{currentAccount?.usage?.primary && (
|
|
96
|
+
<div className="mt-1.5 flex items-center gap-2">
|
|
97
|
+
<div className="flex-1 h-1 bg-bg-tertiary rounded-full overflow-hidden">
|
|
98
|
+
<div
|
|
99
|
+
className={`h-full rounded-full transition-all ${
|
|
100
|
+
currentAccount.usage.primary.usedPercent >= 90 ? 'bg-accent-red' :
|
|
101
|
+
currentAccount.usage.primary.usedPercent >= 80 ? 'bg-yellow-500' :
|
|
102
|
+
'bg-accent-green'
|
|
103
|
+
}`}
|
|
104
|
+
style={{ width: `${Math.min(100, currentAccount.usage.primary.usedPercent)}%` }}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
<span className="text-[9px] text-text-muted">
|
|
108
|
+
{Math.round(currentAccount.usage.primary.usedPercent)}%
|
|
109
|
+
</span>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Available accounts */}
|
|
115
|
+
<div className="py-1">
|
|
116
|
+
{availableAccounts.map((account) => (
|
|
117
|
+
<button
|
|
118
|
+
key={account.id}
|
|
119
|
+
onClick={() => handleSwitch(account.id)}
|
|
120
|
+
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-bg-hover transition-colors text-left"
|
|
121
|
+
>
|
|
122
|
+
<div className={`w-2 h-2 rounded-full ${
|
|
123
|
+
account.status === 'online' ? 'bg-accent-green' : 'bg-text-muted'
|
|
124
|
+
}`} />
|
|
125
|
+
<div className="flex-1 min-w-0">
|
|
126
|
+
<span className="text-xs text-text-primary block truncate">
|
|
127
|
+
{account.name}
|
|
128
|
+
</span>
|
|
129
|
+
{account.usage?.primary && (
|
|
130
|
+
<div className="mt-1 flex items-center gap-2">
|
|
131
|
+
<div className="flex-1 h-1 bg-bg-tertiary rounded-full overflow-hidden">
|
|
132
|
+
<div
|
|
133
|
+
className={`h-full rounded-full transition-all ${
|
|
134
|
+
account.usage.primary.usedPercent >= 90 ? 'bg-accent-red' :
|
|
135
|
+
account.usage.primary.usedPercent >= 80 ? 'bg-yellow-500' :
|
|
136
|
+
'bg-accent-green'
|
|
137
|
+
}`}
|
|
138
|
+
style={{ width: `${Math.min(100, account.usage.primary.usedPercent)}%` }}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
<span className="text-[9px] text-text-muted">
|
|
142
|
+
{Math.round(account.usage.primary.usedPercent)}%
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
<Icons.ArrowRight className="w-3 h-3 text-text-muted shrink-0" />
|
|
148
|
+
</button>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{hasRateLimitWarning && (
|
|
153
|
+
<div className="px-3 py-2 border-t border-border bg-yellow-500/5">
|
|
154
|
+
<p className="text-[10px] text-yellow-500">
|
|
155
|
+
<Icons.Warning className="w-3 h-3 inline mr-1" />
|
|
156
|
+
Current account is near rate limit. Consider switching.
|
|
157
|
+
</p>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className="relative">
|
|
165
|
+
<button
|
|
166
|
+
ref={buttonRef}
|
|
167
|
+
type="button"
|
|
168
|
+
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
169
|
+
disabled={disabled}
|
|
170
|
+
className={`group flex items-center gap-1.5 px-2 py-1 rounded-md transition-colors ${
|
|
171
|
+
disabled
|
|
172
|
+
? 'opacity-50 cursor-not-allowed'
|
|
173
|
+
: 'hover:bg-bg-hover cursor-pointer'
|
|
174
|
+
} ${hasRateLimitWarning ? 'text-yellow-500' : ''}`}
|
|
175
|
+
title={availableAccounts.length > 0 ? 'Switch account for this thread' : 'No other accounts available'}
|
|
176
|
+
>
|
|
177
|
+
<span className={`truncate max-w-[100px] text-[10px] ${hasRateLimitWarning ? 'text-yellow-500' : 'text-text-muted'}`}>
|
|
178
|
+
{currentAccountName || 'Unknown account'}
|
|
179
|
+
</span>
|
|
180
|
+
{hasRateLimitWarning && (
|
|
181
|
+
<Icons.Warning className="w-3 h-3 text-yellow-500 shrink-0" />
|
|
182
|
+
)}
|
|
183
|
+
{availableAccounts.length > 0 && !disabled && (
|
|
184
|
+
<Icons.ChevronDown className={`w-3 h-3 text-text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
185
|
+
)}
|
|
186
|
+
</button>
|
|
187
|
+
|
|
188
|
+
{dropdownContent && createPortal(dropdownContent, document.body)}
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
@@ -184,4 +184,16 @@ export const Icons = {
|
|
|
184
184
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
185
185
|
</svg>
|
|
186
186
|
),
|
|
187
|
+
|
|
188
|
+
ArrowRight: ({ className }: IconProps) => (
|
|
189
|
+
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
190
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
|
191
|
+
</svg>
|
|
192
|
+
),
|
|
193
|
+
|
|
194
|
+
Switch: ({ className }: IconProps) => (
|
|
195
|
+
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
196
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
|
197
|
+
</svg>
|
|
198
|
+
),
|
|
187
199
|
}
|