better-codex 0.2.0 → 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.
@@ -59,10 +59,12 @@ export class JsonRpcConnection extends EventEmitter {
59
59
  })
60
60
 
61
61
  stdout.on('end', () => {
62
+ this.rejectAllPending(new Error('connection closed'))
62
63
  this.emit('close')
63
64
  })
64
65
 
65
66
  stdout.on('error', (error) => {
67
+ this.rejectAllPending(error)
66
68
  this.emit('error', error)
67
69
  })
68
70
 
@@ -161,6 +163,17 @@ export class JsonRpcConnection extends EventEmitter {
161
163
  private writeMessage(message: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification): void {
162
164
  this.stdin.write(`${JSON.stringify(message)}\n`)
163
165
  }
166
+
167
+ private rejectAllPending(error: Error): void {
168
+ if (!this.pending.size) {
169
+ return
170
+ }
171
+ const message = error.message || 'connection closed'
172
+ for (const [, pending] of this.pending) {
173
+ pending.reject({ message })
174
+ }
175
+ this.pending.clear()
176
+ }
164
177
  }
165
178
 
166
179
  export type JsonRpcEventsMap = JsonRpcEvents
@@ -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}
@@ -374,6 +374,7 @@ function getActionType(msg: Message): AssistantAction['type'] {
374
374
  return exploratory ? 'explored' : 'ran'
375
375
  }
376
376
  const title = msg.title?.toLowerCase() ?? ''
377
+ if (title.includes('account switch') || title.includes('new conversation')) return 'chat'
377
378
  if (title.includes('web search')) return 'searched'
378
379
  if (title.includes('read') || title.includes('view') || title.includes('list')) return 'explored'
379
380
  if (title.includes('edit') || title.includes('wrote') || title.includes('creat')) return 'edited'
@@ -678,8 +679,10 @@ export function VirtualizedMessageList({
678
679
  useEffect(() => {
679
680
  if (items.length > 0 && !initialScrollDone.current) {
680
681
  initialScrollDone.current = true
681
- requestAnimationFrame(() => {
682
- virtualizer.scrollToIndex(items.length - 1, { align: 'end' })
682
+ queueMicrotask(() => {
683
+ requestAnimationFrame(() => {
684
+ virtualizer.scrollToIndex(items.length - 1, { align: 'end' })
685
+ })
683
686
  })
684
687
  }
685
688
  }, [items.length, virtualizer])
@@ -707,7 +710,9 @@ export function VirtualizedMessageList({
707
710
 
708
711
  if (items.length > 0 && hasNewItems && !userHasScrolled && initialScrollDone.current) {
709
712
  isAutoScrolling.current = true
710
- virtualizer.scrollToIndex(items.length - 1, { align: 'end', behavior: 'smooth' })
713
+ queueMicrotask(() => {
714
+ virtualizer.scrollToIndex(items.length - 1, { align: 'end', behavior: 'smooth' })
715
+ })
711
716
  setTimeout(() => {
712
717
  isAutoScrolling.current = false
713
718
  }, 500)
@@ -719,11 +724,13 @@ export function VirtualizedMessageList({
719
724
  return
720
725
  }
721
726
  isAutoScrolling.current = true
722
- requestAnimationFrame(() => {
723
- virtualizer.scrollToIndex(items.length - 1, { align: 'end' })
724
- setTimeout(() => {
725
- isAutoScrolling.current = false
726
- }, 200)
727
+ queueMicrotask(() => {
728
+ requestAnimationFrame(() => {
729
+ virtualizer.scrollToIndex(items.length - 1, { align: 'end' })
730
+ setTimeout(() => {
731
+ isAutoScrolling.current = false
732
+ }, 200)
733
+ })
727
734
  })
728
735
  }, [isTaskRunning, userHasScrolled, lastMessageSignature, items.length, virtualizer])
729
736
 
@@ -731,7 +738,9 @@ export function VirtualizedMessageList({
731
738
  if (items.length > 0) {
732
739
  setUserHasScrolled(false)
733
740
  isAutoScrolling.current = true
734
- virtualizer.scrollToIndex(items.length - 1, { align: 'end', behavior: 'smooth' })
741
+ queueMicrotask(() => {
742
+ virtualizer.scrollToIndex(items.length - 1, { align: 'end', behavior: 'smooth' })
743
+ })
735
744
  setTimeout(() => {
736
745
  isAutoScrolling.current = false
737
746
  }, 500)
@@ -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
  }
@@ -78,10 +78,16 @@ export const useHubConnection = () => {
78
78
  setAccountLoginId,
79
79
  upsertReviewSession,
80
80
  updateReviewSession,
81
+ resolveThreadId,
81
82
  } = useAppStore()
82
83
 
83
84
  const nowTimestamp = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
84
85
 
86
+ const resolve = (backendThreadId: string | undefined): string | undefined => {
87
+ if (!backendThreadId) return undefined
88
+ return resolveThreadId(backendThreadId)
89
+ }
90
+
85
91
  const addSystemMessage = (threadId: string, title: string, content: string) => {
86
92
  addMessage(threadId, {
87
93
  id: `sys-${Date.now()}-${Math.random().toString(16).slice(2)}`,
@@ -238,7 +244,8 @@ export const useHubConnection = () => {
238
244
  }
239
245
 
240
246
  if (method === 'turn/started' && params && typeof params === 'object') {
241
- const { threadId, turn } = params as { threadId?: string; turn?: { id?: string } }
247
+ const { threadId: rawThreadId, turn } = params as { threadId?: string; turn?: { id?: string } }
248
+ const threadId = resolve(rawThreadId)
242
249
  if (threadId) {
243
250
  updateThread(threadId, { status: 'active' })
244
251
  setThreadTurnStartedAt(threadId, Date.now())
@@ -249,7 +256,8 @@ export const useHubConnection = () => {
249
256
  }
250
257
 
251
258
  if (method === 'turn/completed' && params && typeof params === 'object') {
252
- const { threadId, turn } = params as { threadId?: string; turn?: { id?: string; status?: string } }
259
+ const { threadId: rawThreadId, turn } = params as { threadId?: string; turn?: { id?: string; status?: string } }
260
+ const threadId = resolve(rawThreadId)
253
261
  // console.log('[HubConnection] turn/completed event for thread:', threadId)
254
262
  if (threadId) {
255
263
  const startedAt = useAppStore.getState().threadTurnStartedAt[threadId]
@@ -271,7 +279,8 @@ export const useHubConnection = () => {
271
279
  }
272
280
 
273
281
  if (method === 'turn/diff/updated' && params && typeof params === 'object') {
274
- const { threadId, turnId, diff } = params as { threadId?: string; turnId?: string; diff?: string }
282
+ const { threadId: rawThreadId, turnId, diff } = params as { threadId?: string; turnId?: string; diff?: string }
283
+ const threadId = resolve(rawThreadId)
275
284
  if (threadId && diff) {
276
285
  const content = diff.length > 4000 ? `${diff.slice(0, 4000)}\n…` : diff
277
286
  upsertMessage(threadId, {
@@ -287,12 +296,13 @@ export const useHubConnection = () => {
287
296
  }
288
297
 
289
298
  if (method === 'turn/plan/updated' && params && typeof params === 'object') {
290
- const { threadId, turnId, plan, explanation } = params as {
299
+ const { threadId: rawThreadId, turnId, plan, explanation } = params as {
291
300
  threadId?: string
292
301
  turnId?: string
293
302
  plan?: Array<{ step?: string; status?: string }>
294
303
  explanation?: string
295
304
  }
305
+ const threadId = resolve(rawThreadId)
296
306
  if (threadId && Array.isArray(plan)) {
297
307
  const steps = plan
298
308
  .map((entry) => `${entry.status ?? 'pending'} · ${entry.step ?? ''}`.trim())
@@ -311,7 +321,8 @@ export const useHubConnection = () => {
311
321
  }
312
322
 
313
323
  if (method === 'thread/tokenUsage/updated' && params && typeof params === 'object') {
314
- const { threadId } = params as { threadId?: string }
324
+ const { threadId: rawThreadId } = params as { threadId?: string }
325
+ const threadId = resolve(rawThreadId)
315
326
  const usage = (params as { usage?: unknown }).usage ?? (params as { tokenUsage?: unknown }).tokenUsage
316
327
  if (threadId && usage) {
317
328
  setThreadTokenUsage(threadId, usage)
@@ -319,73 +330,80 @@ export const useHubConnection = () => {
319
330
  }
320
331
 
321
332
  if (method === 'item/agentMessage/delta' && params && typeof params === 'object') {
322
- const { threadId, itemId, delta } = params as {
333
+ const { threadId: rawThreadId, itemId, delta } = params as {
323
334
  threadId?: string
324
335
  itemId?: string
325
336
  delta?: string
326
337
  }
338
+ const threadId = resolve(rawThreadId)
327
339
  if (threadId && itemId && delta) {
328
340
  appendMessageDelta(threadId, itemId, delta)
329
341
  }
330
342
  }
331
343
 
332
344
  if (method === 'item/reasoning/summaryTextDelta' && params && typeof params === 'object') {
333
- const { threadId, itemId, delta } = params as {
345
+ const { threadId: rawThreadId, itemId, delta } = params as {
334
346
  threadId?: string
335
347
  itemId?: string
336
348
  delta?: string
337
349
  }
350
+ const threadId = resolve(rawThreadId)
338
351
  if (threadId && itemId && delta) {
339
352
  appendMessageDelta(threadId, itemId, delta)
340
353
  }
341
354
  }
342
355
 
343
356
  if (method === 'item/reasoning/summaryPartAdded' && params && typeof params === 'object') {
344
- const { threadId, itemId } = params as { threadId?: string; itemId?: string }
357
+ const { threadId: rawThreadId, itemId } = params as { threadId?: string; itemId?: string }
358
+ const threadId = resolve(rawThreadId)
345
359
  if (threadId && itemId) {
346
360
  appendMessageDelta(threadId, itemId, '\n\n')
347
361
  }
348
362
  }
349
363
 
350
364
  if (method === 'item/reasoning/textDelta' && params && typeof params === 'object') {
351
- const { threadId, itemId, delta } = params as {
365
+ const { threadId: rawThreadId, itemId, delta } = params as {
352
366
  threadId?: string
353
367
  itemId?: string
354
368
  delta?: string
355
369
  }
370
+ const threadId = resolve(rawThreadId)
356
371
  if (threadId && itemId && delta) {
357
372
  appendMessageDelta(threadId, itemId, delta)
358
373
  }
359
374
  }
360
375
 
361
376
  if (method === 'item/commandExecution/outputDelta' && params && typeof params === 'object') {
362
- const { threadId, itemId, delta } = params as {
377
+ const { threadId: rawThreadId, itemId, delta } = params as {
363
378
  threadId?: string
364
379
  itemId?: string
365
380
  delta?: string
366
381
  }
382
+ const threadId = resolve(rawThreadId)
367
383
  if (threadId && itemId && delta) {
368
384
  appendMessageDelta(threadId, itemId, delta)
369
385
  }
370
386
  }
371
387
 
372
388
  if (method === 'item/fileChange/outputDelta' && params && typeof params === 'object') {
373
- const { threadId, itemId, delta } = params as {
389
+ const { threadId: rawThreadId, itemId, delta } = params as {
374
390
  threadId?: string
375
391
  itemId?: string
376
392
  delta?: string
377
393
  }
394
+ const threadId = resolve(rawThreadId)
378
395
  if (threadId && itemId && delta) {
379
396
  appendMessageDelta(threadId, itemId, delta)
380
397
  }
381
398
  }
382
399
 
383
400
  if (method === 'item/started' && params && typeof params === 'object') {
384
- const { item, threadId, turnId } = params as {
401
+ const { item, threadId: rawThreadId, turnId } = params as {
385
402
  item?: ItemPayload & { review?: string }
386
403
  threadId?: string
387
404
  turnId?: string
388
405
  }
406
+ const threadId = resolve(rawThreadId)
389
407
  if (threadId && item?.type === 'agentMessage' && item.id) {
390
408
  ensureAssistantMessage(threadId, item.id)
391
409
  return
@@ -411,11 +429,12 @@ export const useHubConnection = () => {
411
429
  }
412
430
 
413
431
  if (method === 'item/completed' && params && typeof params === 'object') {
414
- const { item, threadId, turnId } = params as {
432
+ const { item, threadId: rawThreadId, turnId } = params as {
415
433
  item?: ItemPayload & { review?: string }
416
434
  threadId?: string
417
435
  turnId?: string
418
436
  }
437
+ const threadId = resolve(rawThreadId)
419
438
  if (threadId && item?.type === 'exitedReviewMode') {
420
439
  const sessionId = turnId ?? item.id
421
440
  if (sessionId) {
@@ -436,7 +455,8 @@ export const useHubConnection = () => {
436
455
  }
437
456
 
438
457
  if (method === 'error' && params && typeof params === 'object') {
439
- const { threadId, error } = params as { threadId?: string; error?: { message?: string } }
458
+ const { threadId: rawThreadId, error } = params as { threadId?: string; error?: { message?: string } }
459
+ const threadId = resolve(rawThreadId)
440
460
  if (threadId) {
441
461
  const message = error?.message ?? 'Unknown error.'
442
462
  addSystemMessage(threadId, 'Error', message)
@@ -467,11 +487,12 @@ export const useHubConnection = () => {
467
487
  parsedCmd?: string
468
488
  command?: string[]
469
489
  }
490
+ const resolvedThreadId = resolve(parsed.threadId) ?? ''
470
491
  addApproval({
471
492
  id: parsed.itemId ?? String(id),
472
493
  requestId: id,
473
494
  profileId,
474
- threadId: parsed.threadId ?? '',
495
+ threadId: resolvedThreadId,
475
496
  type: 'command',
476
497
  payload: parsed.parsedCmd ?? parsed.command?.join(' ') ?? 'Command approval required',
477
498
  status: 'pending',
@@ -484,11 +505,12 @@ export const useHubConnection = () => {
484
505
  threadId?: string
485
506
  reason?: string
486
507
  }
508
+ const resolvedThreadId = resolve(parsed.threadId) ?? ''
487
509
  addApproval({
488
510
  id: parsed.itemId ?? String(id),
489
511
  requestId: id,
490
512
  profileId,
491
- threadId: parsed.threadId ?? '',
513
+ threadId: resolvedThreadId,
492
514
  type: 'file',
493
515
  payload: parsed.reason ?? 'File changes requested',
494
516
  status: 'pending',
@@ -20,6 +20,8 @@ interface AppState {
20
20
  threadTurnStartedAt: Record<string, number>
21
21
  threadLastTurnDuration: Record<string, number>
22
22
  threadTokenUsage: Record<string, unknown>
23
+ threadPendingAccountSwitch: Record<string, { originalThreadId: string; previousAccountId: string }>
24
+ backendToUiThreadId: Record<string, string>
23
25
 
24
26
  messages: Record<string, Message[]>
25
27
  queuedMessages: Record<string, QueuedMessage[]>
@@ -59,6 +61,9 @@ interface AppState {
59
61
  setThreadTurnStartedAt: (threadId: string, startedAt: number | null) => void
60
62
  setThreadLastTurnDuration: (threadId: string, duration: number | null) => void
61
63
  setThreadTokenUsage: (threadId: string, usage: unknown) => void
64
+ setThreadPendingAccountSwitch: (threadId: string, pending: { originalThreadId: string; previousAccountId: string } | null) => void
65
+ setBackendToUiThreadId: (backendThreadId: string, uiThreadId: string | null) => void
66
+ resolveThreadId: (threadId: string) => string
62
67
 
63
68
  addMessage: (threadId: string, message: Message) => void
64
69
  appendAgentDelta: (threadId: string, messageId: string, delta: string) => void
@@ -87,7 +92,7 @@ interface AppState {
87
92
  closeMobileDrawers: () => void
88
93
  }
89
94
 
90
- export const useAppStore = create<AppState>((set) => ({
95
+ export const useAppStore = create<AppState>((set, get) => ({
91
96
  accounts: [],
92
97
  selectedAccountId: null,
93
98
  connectionStatus: 'idle',
@@ -105,6 +110,8 @@ export const useAppStore = create<AppState>((set) => ({
105
110
  threadTurnStartedAt: {},
106
111
  threadLastTurnDuration: {},
107
112
  threadTokenUsage: {},
113
+ threadPendingAccountSwitch: {},
114
+ backendToUiThreadId: {},
108
115
  messages: {},
109
116
  queuedMessages: {},
110
117
  approvals: [],
@@ -325,6 +332,34 @@ export const useAppStore = create<AppState>((set) => ({
325
332
  [threadId]: usage,
326
333
  },
327
334
  })),
335
+ setThreadPendingAccountSwitch: (threadId, pending) => set((state) => {
336
+ if (pending === null) {
337
+ const { [threadId]: _, ...rest } = state.threadPendingAccountSwitch
338
+ return { threadPendingAccountSwitch: rest }
339
+ }
340
+ return {
341
+ threadPendingAccountSwitch: {
342
+ ...state.threadPendingAccountSwitch,
343
+ [threadId]: pending,
344
+ },
345
+ }
346
+ }),
347
+ setBackendToUiThreadId: (backendThreadId, uiThreadId) => set((state) => {
348
+ if (uiThreadId === null) {
349
+ const { [backendThreadId]: _, ...rest } = state.backendToUiThreadId
350
+ return { backendToUiThreadId: rest }
351
+ }
352
+ return {
353
+ backendToUiThreadId: {
354
+ ...state.backendToUiThreadId,
355
+ [backendThreadId]: uiThreadId,
356
+ },
357
+ }
358
+ }),
359
+ resolveThreadId: (threadId: string): string => {
360
+ const state = get()
361
+ return state.backendToUiThreadId[threadId] ?? threadId
362
+ },
328
363
 
329
364
  addMessage: (threadId, message) => set((state) => ({
330
365
  messages: {
@@ -83,6 +83,7 @@ export interface Thread {
83
83
  createdAt: string
84
84
  status: ThreadStatus
85
85
  messageCount: number
86
+ backendThreadId?: string
86
87
  }
87
88
 
88
89
  export interface Message {
@@ -4,6 +4,7 @@ export type SlashCommandId =
4
4
  | 'cwd'
5
5
  | 'approvals'
6
6
  | 'skills'
7
+ | 'switch'
7
8
  | 'review'
8
9
  | 'new'
9
10
  | 'resume'
@@ -32,6 +33,7 @@ export const SLASH_COMMANDS: SlashCommandDefinition[] = [
32
33
  { id: 'cwd', description: 'set working directory for the thread', availableDuringTask: false },
33
34
  { id: 'approvals', description: 'choose what Codex can do without approval', availableDuringTask: false },
34
35
  { id: 'skills', description: 'browse and insert skills', availableDuringTask: true },
36
+ { id: 'switch', description: 'switch to a different account for this thread', availableDuringTask: false },
35
37
  { id: 'review', description: 'review my current changes and find issues', availableDuringTask: false },
36
38
  { id: 'new', description: 'start a new chat during a conversation', availableDuringTask: false },
37
39
  { id: 'resume', description: 'resume a saved chat', availableDuringTask: false },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-codex",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Web launcher for Codex Hub",
5
5
  "bin": {
6
6
  "better-codex": "bin/better-codex.cjs"