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.
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { useEffect, useRef, useState, useMemo } from 'react'
2
2
  import { useAppStore } from '../../store'
3
3
  import { type SelectOption } from '../ui'
4
4
  import { VirtualizedMessageList } from './virtualized-message-list'
@@ -15,6 +15,7 @@ import { SessionAuthBanner } from '../session-view/session-auth-banner'
15
15
  import { SessionComposer } from '../session-view/session-composer'
16
16
  import { SessionDialogs } from '../session-view/session-dialogs'
17
17
  import { SessionEmpty } from '../session-view/session-empty'
18
+ import { RateLimitBanner, isRateLimitError } from '../session-view/rate-limit-banner'
18
19
 
19
20
  export function SessionView() {
20
21
  const [inputValue, setInputValue] = useState('')
@@ -37,6 +38,8 @@ export function SessionView() {
37
38
  const [pendingCwd, setPendingCwd] = useState('')
38
39
  const [pendingApproval, setPendingApproval] = useState<ApprovalPolicy>('on-request')
39
40
  const [showApiKeyPrompt, setShowApiKeyPrompt] = useState(false)
41
+ const [showAccountSwitchDialog, setShowAccountSwitchDialog] = useState(false)
42
+ const [rateLimitBannerDismissed, setRateLimitBannerDismissed] = useState(false)
40
43
  // Attachments and file mentions state
41
44
  const [attachments, setAttachments] = useState<Attachment[]>([])
42
45
  const [fileMentions, setFileMentions] = useState<FileMention[]>([])
@@ -77,6 +80,7 @@ export function SessionView() {
77
80
  threadTurnStartedAt,
78
81
  threadLastTurnDuration,
79
82
  threadTokenUsage,
83
+ threadPendingAccountSwitch,
80
84
  setThreadModel,
81
85
  setThreadEffort,
82
86
  setThreadApproval,
@@ -93,6 +97,8 @@ export function SessionView() {
93
97
  connectionStatus,
94
98
  setMessagesForThread,
95
99
  setAccountLoginId,
100
+ setThreadPendingAccountSwitch,
101
+ setBackendToUiThreadId,
96
102
  } = useAppStore()
97
103
 
98
104
  const selectedThread = threads.find((thread) => thread.id === selectedThreadId)
@@ -119,6 +125,9 @@ export function SessionView() {
119
125
  const selectedCwd = selectedThreadId ? threadCwds[selectedThreadId] : undefined
120
126
  const selectedUsage = selectedThreadId ? threadTokenUsage[selectedThreadId] : undefined
121
127
  const webSearchEnabled = selectedThreadId ? threadWebSearch[selectedThreadId] ?? false : false
128
+ const selectedWebSearch = webSearchEnabled
129
+ // Get the effective backend thread ID (may differ from UI thread ID after account switch)
130
+ const effectiveBackendThreadId = selectedThread?.backendThreadId ?? selectedThreadId
122
131
  const isAccountReady = account?.status === 'online'
123
132
  const isAuthPending = account?.status === 'degraded'
124
133
  const canInteract = connectionStatus === 'connected' && !isArchived && isAccountReady
@@ -143,6 +152,34 @@ export function SessionView() {
143
152
  const mentionQuery = getMentionQuery(inputValue)
144
153
  const mentionMenuOpen = mentionQuery !== null && !slashMenuOpen
145
154
  const mentionMatches = mentionMenuOpen ? fileSearchResults : []
155
+
156
+ // Rate limit detection - check recent messages for rate limit errors
157
+ const rateLimitError = useMemo(() => {
158
+ if (!threadMessages.length) return null
159
+ // Check the last 5 messages for rate limit errors
160
+ const recentMessages = threadMessages.slice(-5)
161
+ for (const msg of recentMessages) {
162
+ if (msg.role === 'assistant' && msg.title === 'Error' && isRateLimitError(msg.content)) {
163
+ return msg.content
164
+ }
165
+ }
166
+ return null
167
+ }, [threadMessages])
168
+
169
+ // Get accounts available for switching (online accounts other than current)
170
+ const switchableAccounts = useMemo(() => {
171
+ return accounts.filter(
172
+ (a) => a.id !== threadAccountId && a.status === 'online'
173
+ )
174
+ }, [accounts, threadAccountId])
175
+
176
+ // Show rate limit banner when error detected and not dismissed
177
+ const showRateLimitBanner = !!rateLimitError && !rateLimitBannerDismissed && switchableAccounts.length > 0
178
+
179
+ // Reset banner dismissed state when thread changes
180
+ useEffect(() => {
181
+ setRateLimitBannerDismissed(false)
182
+ }, [selectedThreadId])
146
183
 
147
184
  const modelOptions = models.map((model): SelectOption => ({
148
185
  value: model.id,
@@ -426,6 +463,69 @@ export function SessionView() {
426
463
  try {
427
464
  updateThread(selectedThreadId, { status: 'active' })
428
465
 
466
+ // Check if this thread has a pending account switch
467
+ // If so, we need to start a new thread on the new account
468
+ const pendingSwitch = threadPendingAccountSwitch[selectedThreadId]
469
+ let actualThreadId = selectedThreadId
470
+
471
+ if (pendingSwitch) {
472
+ // Start a new thread on the new account
473
+ const newAccountId = selectedThread.accountId
474
+ const accountModels = modelsByAccount[newAccountId] || []
475
+ const defaultThreadModel = accountModels.find((model) => model.isDefault) ?? accountModels[0]
476
+ const startParams: { model?: string; approvalPolicy?: ApprovalPolicy; config?: Record<string, unknown> } = {}
477
+ if (defaultThreadModel?.id) {
478
+ startParams.model = defaultThreadModel.id
479
+ }
480
+ if (selectedApproval) {
481
+ startParams.approvalPolicy = selectedApproval
482
+ }
483
+ if (selectedWebSearch) {
484
+ startParams.config = { 'features.web_search_request': true }
485
+ }
486
+
487
+ const result = (await hubClient.request(newAccountId, 'thread/start', startParams)) as {
488
+ thread?: {
489
+ id: string
490
+ preview?: string
491
+ modelProvider?: string
492
+ createdAt?: number
493
+ }
494
+ reasoningEffort?: ReasoningEffort | null
495
+ approvalPolicy?: ApprovalPolicy | null
496
+ }
497
+
498
+ if (!result.thread) {
499
+ throw new Error('Failed to start new thread on switched account')
500
+ }
501
+
502
+ // Use the new backend thread ID for this turn
503
+ actualThreadId = result.thread.id
504
+
505
+ // Map the backend thread ID to the UI thread ID so events get routed correctly
506
+ setBackendToUiThreadId(actualThreadId, selectedThreadId)
507
+
508
+ // Update the local thread to use the new backend thread ID
509
+ // We keep the same local thread but update its properties
510
+ updateThread(selectedThreadId, {
511
+ backendThreadId: actualThreadId,
512
+ status: 'active',
513
+ })
514
+
515
+ // Clear the pending switch flag
516
+ setThreadPendingAccountSwitch(selectedThreadId, null)
517
+
518
+ // Add a system message noting the new thread was created
519
+ addMessage(selectedThreadId, {
520
+ id: `sys-new-thread-${Date.now()}`,
521
+ role: 'assistant',
522
+ content: `New conversation started on this account. Previous context from ${pendingSwitch.previousAccountId} is shown for reference but not available to the model.`,
523
+ kind: 'tool',
524
+ title: 'New Conversation',
525
+ timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
526
+ })
527
+ }
528
+
429
529
  // Build input array with text, images, and file references
430
530
  const input: Array<{ type: string; text?: string; url?: string; path?: string }> = []
431
531
 
@@ -453,7 +553,7 @@ export function SessionView() {
453
553
  cwd?: string
454
554
  approvalPolicy?: ApprovalPolicy
455
555
  } = {
456
- threadId: selectedThreadId,
556
+ threadId: actualThreadId,
457
557
  input,
458
558
  }
459
559
  if (effectiveModel) {
@@ -656,14 +756,14 @@ export function SessionView() {
656
756
  }
657
757
 
658
758
  const runReviewCommand = async (instructions?: string) => {
659
- if (!selectedThreadId || !account) {
759
+ if (!selectedThreadId || !account || !effectiveBackendThreadId) {
660
760
  return
661
761
  }
662
762
  const target = instructions
663
763
  ? { type: 'custom', instructions }
664
764
  : { type: 'uncommittedChanges' }
665
765
  await hubClient.request(account.id, 'review/start', {
666
- threadId: selectedThreadId,
766
+ threadId: effectiveBackendThreadId,
667
767
  target,
668
768
  delivery: 'inline',
669
769
  })
@@ -792,6 +892,36 @@ export function SessionView() {
792
892
  setShowApprovalsDialog(true)
793
893
  return
794
894
  }
895
+ case 'switch': {
896
+ // If an account name is provided, try to switch directly
897
+ if (rest) {
898
+ const targetAccount = accounts.find(
899
+ (a) => a.name.toLowerCase() === rest.toLowerCase() ||
900
+ a.id === rest
901
+ )
902
+ if (targetAccount && targetAccount.status === 'online' && targetAccount.id !== threadAccountId) {
903
+ handleSwitchThreadAccount(targetAccount.id)
904
+ return
905
+ }
906
+ if (targetAccount && targetAccount.id === threadAccountId) {
907
+ addSystemMessage('tool', '/switch', `Already using account: ${targetAccount.name}`)
908
+ return
909
+ }
910
+ if (targetAccount && targetAccount.status !== 'online') {
911
+ addSystemMessage('tool', '/switch', `Account "${targetAccount.name}" is not authenticated.`)
912
+ return
913
+ }
914
+ addSystemMessage('tool', '/switch', `Account "${rest}" not found.`)
915
+ return
916
+ }
917
+ // Show dialog if no account specified
918
+ if (switchableAccounts.length === 0) {
919
+ addSystemMessage('tool', '/switch', 'No other authenticated accounts available to switch to.')
920
+ return
921
+ }
922
+ setShowAccountSwitchDialog(true)
923
+ return
924
+ }
795
925
  case 'review': {
796
926
  await runReviewCommand(rest || undefined)
797
927
  return
@@ -966,12 +1096,12 @@ export function SessionView() {
966
1096
  }
967
1097
 
968
1098
  const handleArchive = async () => {
969
- if (!selectedThreadId || !selectedThread || !canInteract) {
1099
+ if (!selectedThreadId || !selectedThread || !canInteract || !effectiveBackendThreadId) {
970
1100
  return
971
1101
  }
972
1102
  try {
973
1103
  await hubClient.request(selectedThread.accountId, 'thread/archive', {
974
- threadId: selectedThreadId,
1104
+ threadId: effectiveBackendThreadId,
975
1105
  })
976
1106
  updateThread(selectedThreadId, { status: 'archived' })
977
1107
  clearQueuedMessages(selectedThreadId)
@@ -1123,7 +1253,7 @@ export function SessionView() {
1123
1253
  }
1124
1254
 
1125
1255
  const handleInterruptTurn = async () => {
1126
- if (!account || !selectedThreadId) {
1256
+ if (!account || !selectedThreadId || !effectiveBackendThreadId) {
1127
1257
  return
1128
1258
  }
1129
1259
  const turnId = threadTurnIds[selectedThreadId]
@@ -1132,7 +1262,7 @@ export function SessionView() {
1132
1262
  }
1133
1263
  try {
1134
1264
  await hubClient.request(account.id, 'turn/interrupt', {
1135
- threadId: selectedThreadId,
1265
+ threadId: effectiveBackendThreadId,
1136
1266
  turnId,
1137
1267
  })
1138
1268
  } catch {
@@ -1213,6 +1343,55 @@ export function SessionView() {
1213
1343
  }
1214
1344
  }
1215
1345
 
1346
+ const handleSwitchThreadAccount = (newAccountId: string) => {
1347
+ if (!selectedThreadId || !selectedThread) {
1348
+ return
1349
+ }
1350
+
1351
+ const newAccount = accounts.find((a) => a.id === newAccountId)
1352
+ if (!newAccount || newAccount.status !== 'online') {
1353
+ setAlertDialog({
1354
+ open: true,
1355
+ title: 'Account Unavailable',
1356
+ message: 'Selected account is not authenticated. Please sign in first.',
1357
+ variant: 'warning',
1358
+ })
1359
+ return
1360
+ }
1361
+
1362
+ const previousAccountId = selectedThread.accountId
1363
+
1364
+ // Mark this thread as having a pending account switch
1365
+ // The next message sent will start a fresh conversation on the new account
1366
+ setThreadPendingAccountSwitch(selectedThreadId, {
1367
+ originalThreadId: selectedThreadId,
1368
+ previousAccountId,
1369
+ })
1370
+
1371
+ // Update thread's accountId
1372
+ updateThread(selectedThreadId, { accountId: newAccountId })
1373
+
1374
+ // Reset model selection to the new account's default model
1375
+ const newAccountModels = modelsByAccount[newAccountId] || []
1376
+ const newDefaultModel = newAccountModels.find((m) => m.isDefault) ?? newAccountModels[0]
1377
+ if (newDefaultModel) {
1378
+ setThreadModel(selectedThreadId, newDefaultModel.id)
1379
+ if (newDefaultModel.defaultReasoningEffort) {
1380
+ setThreadEffort(selectedThreadId, newDefaultModel.defaultReasoningEffort)
1381
+ }
1382
+ }
1383
+
1384
+ // Add a system message to indicate the switch
1385
+ addMessage(selectedThreadId, {
1386
+ id: `sys-switch-${Date.now()}`,
1387
+ role: 'assistant',
1388
+ content: `Switched to account: ${newAccount.name}. Your next message will start a new conversation using this account.`,
1389
+ kind: 'tool',
1390
+ title: 'Account Switch',
1391
+ timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
1392
+ })
1393
+ }
1394
+
1216
1395
  if (!selectedThread) {
1217
1396
  return <SessionEmpty onNewSession={handleEmptyNewSession} />
1218
1397
  }
@@ -1221,11 +1400,14 @@ export function SessionView() {
1221
1400
  <main className="flex-1 flex flex-col h-full bg-bg-primary overflow-hidden">
1222
1401
  <SessionHeader
1223
1402
  title={selectedThread.title}
1403
+ accountId={selectedThread.accountId}
1224
1404
  accountName={account?.name}
1405
+ accounts={accounts}
1225
1406
  model={selectedThread.model}
1226
1407
  status={selectedThread.status}
1227
1408
  canInteract={canInteract}
1228
1409
  onArchive={handleArchive}
1410
+ onSwitchAccount={handleSwitchThreadAccount}
1229
1411
  />
1230
1412
  <SessionAuthBanner
1231
1413
  visible={!isAccountReady}
@@ -1235,6 +1417,14 @@ export function SessionView() {
1235
1417
  onCancel={account?.id && accountLoginIds[account.id] ? handleCancelAuth : undefined}
1236
1418
  onRefresh={account ? () => void refreshAccountStatus(account.id) : undefined}
1237
1419
  />
1420
+ <RateLimitBanner
1421
+ visible={showRateLimitBanner}
1422
+ currentAccount={account}
1423
+ availableAccounts={switchableAccounts}
1424
+ errorMessage={rateLimitError ?? undefined}
1425
+ onSwitchAccount={handleSwitchThreadAccount}
1426
+ onDismiss={() => setRateLimitBannerDismissed(true)}
1427
+ />
1238
1428
  <VirtualizedMessageList
1239
1429
  messages={threadMessages}
1240
1430
  approvals={pendingApprovals}
@@ -1347,6 +1537,11 @@ export function SessionView() {
1347
1537
  onCloseResumeDialog={() => setShowResumeDialog(false)}
1348
1538
  resumeCandidates={resumeCandidates}
1349
1539
  onResumeThread={handleResumeThread}
1540
+ showAccountSwitchDialog={showAccountSwitchDialog}
1541
+ onCloseAccountSwitchDialog={() => setShowAccountSwitchDialog(false)}
1542
+ currentAccount={account}
1543
+ switchableAccounts={switchableAccounts}
1544
+ onSwitchAccount={handleSwitchThreadAccount}
1350
1545
  showFeedbackDialog={showFeedbackDialog}
1351
1546
  onCloseFeedbackDialog={() => setShowFeedbackDialog(false)}
1352
1547
  feedbackCategory={feedbackCategory}
@@ -1,12 +1,13 @@
1
1
  import { useState } from 'react'
2
2
  import { Dialog, Button, Icons } from '../ui'
3
+ import { CodexSettings } from './codex-settings'
3
4
 
4
5
  interface SettingsDialogProps {
5
6
  open: boolean
6
7
  onClose: () => void
7
8
  }
8
9
 
9
- type SettingsTab = 'general' | 'accounts' | 'appearance' | 'shortcuts'
10
+ type SettingsTab = 'general' | 'accounts' | 'codex' | 'appearance' | 'shortcuts'
10
11
 
11
12
  export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
12
13
  const [activeTab, setActiveTab] = useState<SettingsTab>('general')
@@ -29,6 +30,12 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
29
30
  active={activeTab === 'accounts'}
30
31
  onClick={() => setActiveTab('accounts')}
31
32
  />
33
+ <SettingsNavItem
34
+ icon={<Icons.Terminal className="w-4 h-4" />}
35
+ label="Codex"
36
+ active={activeTab === 'codex'}
37
+ onClick={() => setActiveTab('codex')}
38
+ />
32
39
  <SettingsNavItem
33
40
  icon={<Icons.Bolt className="w-4 h-4" />}
34
41
  label="Appearance"
@@ -52,6 +59,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
52
59
  <div className="flex-1 overflow-y-auto">
53
60
  {activeTab === 'general' && <GeneralSettings />}
54
61
  {activeTab === 'accounts' && <AccountsSettings />}
62
+ {activeTab === 'codex' && <CodexSettings />}
55
63
  {activeTab === 'appearance' && <AppearanceSettings />}
56
64
  {activeTab === 'shortcuts' && <ShortcutsSettings />}
57
65
  </div>