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.
@@ -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-2 mt-0.5 text-[10px] text-text-muted">
27
- <span className="truncate max-w-[100px]">{accountName || 'Unknown account'}</span>
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
  }