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.
- package/apps/backend/src/core/jsonrpc.ts +13 -0
- package/apps/web/src/components/layout/session-view.tsx +203 -8
- package/apps/web/src/components/layout/virtualized-message-list.tsx +18 -9
- 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 +38 -16
- package/apps/web/src/store/index.ts +36 -1
- package/apps/web/src/types/index.ts +1 -0
- package/apps/web/src/utils/slash-commands.ts +2 -0
- package/package.json +1 -1
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
}
|
|
@@ -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:
|
|
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:
|
|
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: {
|
|
@@ -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 },
|