better-codex 0.2.0 → 0.2.2
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/backend/src/server.ts +19 -0
- package/apps/web/src/components/layout/session-view.tsx +301 -11
- package/apps/web/src/components/layout/side-bar.tsx +265 -49
- package/apps/web/src/components/layout/virtualized-message-list.tsx +46 -15
- package/apps/web/src/components/session-view/rate-limit-banner.tsx +178 -0
- package/apps/web/src/components/session-view/session-auth-banner.tsx +31 -28
- package/apps/web/src/components/session-view/session-composer.tsx +20 -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 +18 -0
- package/apps/web/src/hooks/use-hub-connection.ts +58 -17
- package/apps/web/src/hooks/use-thread-history.ts +26 -1
- package/apps/web/src/services/hub-client.ts +9 -0
- package/apps/web/src/store/index.ts +72 -2
- package/apps/web/src/types/index.ts +7 -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
|
|
@@ -371,6 +371,25 @@ const app = new Elysia()
|
|
|
371
371
|
}),
|
|
372
372
|
}
|
|
373
373
|
)
|
|
374
|
+
.delete(
|
|
375
|
+
'/threads/active/:threadId',
|
|
376
|
+
({ params, query }) => {
|
|
377
|
+
const profileId = typeof query.profileId === 'string' ? query.profileId : undefined
|
|
378
|
+
if (!profileId) {
|
|
379
|
+
return { ok: false, error: 'profileId is required' }
|
|
380
|
+
}
|
|
381
|
+
threadActivity.markCompleted(profileId, params.threadId)
|
|
382
|
+
return { ok: true }
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
params: t.Object({
|
|
386
|
+
threadId: t.String(),
|
|
387
|
+
}),
|
|
388
|
+
query: t.Object({
|
|
389
|
+
profileId: t.String(),
|
|
390
|
+
}),
|
|
391
|
+
}
|
|
392
|
+
)
|
|
374
393
|
.post(
|
|
375
394
|
'/threads/reindex',
|
|
376
395
|
async ({ body }) => {
|
|
@@ -1,9 +1,9 @@
|
|
|
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'
|
|
5
5
|
import { hubClient } from '../../services/hub-client'
|
|
6
|
-
import type { ApprovalPolicy, Attachment, FileMention, MessageKind, ReasoningEffort, ReasoningSummary } from '../../types'
|
|
6
|
+
import type { ApprovalPolicy, Attachment, FileMention, MessageKind, ReasoningEffort, ReasoningSummary, SandboxMode, SandboxPolicy } from '../../types'
|
|
7
7
|
import { INIT_PROMPT } from '../../utils/init-prompt'
|
|
8
8
|
import { filterSlashCommands, findSlashCommand, getSlashQuery, parseSlashInput, type SlashCommandDefinition } from '../../utils/slash-commands'
|
|
9
9
|
import { approvalPolicyDescription, approvalPolicyLabel, normalizeApprovalPolicy } from '../../utils/approval-policy'
|
|
@@ -15,6 +15,23 @@ 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'
|
|
19
|
+
|
|
20
|
+
const ALWAYS_ALLOW_SANDBOX_MODE: SandboxMode = 'workspace-write'
|
|
21
|
+
const ALWAYS_ALLOW_SANDBOX_POLICY: SandboxPolicy = { type: 'workspaceWrite' }
|
|
22
|
+
|
|
23
|
+
const resolveSandboxPolicy = (mode?: SandboxMode | null): SandboxPolicy | null => {
|
|
24
|
+
switch (mode) {
|
|
25
|
+
case 'danger-full-access':
|
|
26
|
+
return { type: 'dangerFullAccess' }
|
|
27
|
+
case 'read-only':
|
|
28
|
+
return { type: 'readOnly' }
|
|
29
|
+
case 'workspace-write':
|
|
30
|
+
return { type: 'workspaceWrite' }
|
|
31
|
+
default:
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
18
35
|
|
|
19
36
|
export function SessionView() {
|
|
20
37
|
const [inputValue, setInputValue] = useState('')
|
|
@@ -37,6 +54,8 @@ export function SessionView() {
|
|
|
37
54
|
const [pendingCwd, setPendingCwd] = useState('')
|
|
38
55
|
const [pendingApproval, setPendingApproval] = useState<ApprovalPolicy>('on-request')
|
|
39
56
|
const [showApiKeyPrompt, setShowApiKeyPrompt] = useState(false)
|
|
57
|
+
const [showAccountSwitchDialog, setShowAccountSwitchDialog] = useState(false)
|
|
58
|
+
const [rateLimitBannerDismissed, setRateLimitBannerDismissed] = useState(false)
|
|
40
59
|
// Attachments and file mentions state
|
|
41
60
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
|
42
61
|
const [fileMentions, setFileMentions] = useState<FileMention[]>([])
|
|
@@ -73,14 +92,17 @@ export function SessionView() {
|
|
|
73
92
|
threadEfforts,
|
|
74
93
|
threadApprovals,
|
|
75
94
|
threadWebSearch,
|
|
95
|
+
threadSandboxes,
|
|
76
96
|
threadTurnIds,
|
|
77
97
|
threadTurnStartedAt,
|
|
78
98
|
threadLastTurnDuration,
|
|
79
99
|
threadTokenUsage,
|
|
100
|
+
threadPendingAccountSwitch,
|
|
80
101
|
setThreadModel,
|
|
81
102
|
setThreadEffort,
|
|
82
103
|
setThreadApproval,
|
|
83
104
|
setThreadWebSearch,
|
|
105
|
+
setThreadSandbox,
|
|
84
106
|
threadSummaries,
|
|
85
107
|
setThreadSummary,
|
|
86
108
|
threadCwds,
|
|
@@ -93,6 +115,8 @@ export function SessionView() {
|
|
|
93
115
|
connectionStatus,
|
|
94
116
|
setMessagesForThread,
|
|
95
117
|
setAccountLoginId,
|
|
118
|
+
setThreadPendingAccountSwitch,
|
|
119
|
+
setBackendToUiThreadId,
|
|
96
120
|
} = useAppStore()
|
|
97
121
|
|
|
98
122
|
const selectedThread = threads.find((thread) => thread.id === selectedThreadId)
|
|
@@ -115,10 +139,14 @@ export function SessionView() {
|
|
|
115
139
|
const selectedEffort = selectedThreadId ? threadEfforts[selectedThreadId] : undefined
|
|
116
140
|
const effectiveEffort = selectedEffort ?? defaultEffort ?? null
|
|
117
141
|
const selectedApproval = selectedThreadId ? threadApprovals[selectedThreadId] : undefined
|
|
142
|
+
const selectedSandbox = selectedThreadId ? threadSandboxes[selectedThreadId] : undefined
|
|
118
143
|
const selectedSummary = selectedThreadId ? threadSummaries[selectedThreadId] : undefined
|
|
119
144
|
const selectedCwd = selectedThreadId ? threadCwds[selectedThreadId] : undefined
|
|
120
145
|
const selectedUsage = selectedThreadId ? threadTokenUsage[selectedThreadId] : undefined
|
|
121
146
|
const webSearchEnabled = selectedThreadId ? threadWebSearch[selectedThreadId] ?? false : false
|
|
147
|
+
const selectedWebSearch = webSearchEnabled
|
|
148
|
+
// Get the effective backend thread ID (may differ from UI thread ID after account switch)
|
|
149
|
+
const effectiveBackendThreadId = selectedThread?.backendThreadId ?? selectedThreadId
|
|
122
150
|
const isAccountReady = account?.status === 'online'
|
|
123
151
|
const isAuthPending = account?.status === 'degraded'
|
|
124
152
|
const canInteract = connectionStatus === 'connected' && !isArchived && isAccountReady
|
|
@@ -143,6 +171,34 @@ export function SessionView() {
|
|
|
143
171
|
const mentionQuery = getMentionQuery(inputValue)
|
|
144
172
|
const mentionMenuOpen = mentionQuery !== null && !slashMenuOpen
|
|
145
173
|
const mentionMatches = mentionMenuOpen ? fileSearchResults : []
|
|
174
|
+
|
|
175
|
+
// Rate limit detection - check recent messages for rate limit errors
|
|
176
|
+
const rateLimitError = useMemo(() => {
|
|
177
|
+
if (!threadMessages.length) return null
|
|
178
|
+
// Check the last 5 messages for rate limit errors
|
|
179
|
+
const recentMessages = threadMessages.slice(-5)
|
|
180
|
+
for (const msg of recentMessages) {
|
|
181
|
+
if (msg.role === 'assistant' && msg.title === 'Error' && isRateLimitError(msg.content)) {
|
|
182
|
+
return msg.content
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null
|
|
186
|
+
}, [threadMessages])
|
|
187
|
+
|
|
188
|
+
// Get accounts available for switching (online accounts other than current)
|
|
189
|
+
const switchableAccounts = useMemo(() => {
|
|
190
|
+
return accounts.filter(
|
|
191
|
+
(a) => a.id !== threadAccountId && a.status === 'online'
|
|
192
|
+
)
|
|
193
|
+
}, [accounts, threadAccountId])
|
|
194
|
+
|
|
195
|
+
// Show rate limit banner when error detected and not dismissed
|
|
196
|
+
const showRateLimitBanner = !!rateLimitError && !rateLimitBannerDismissed && switchableAccounts.length > 0
|
|
197
|
+
|
|
198
|
+
// Reset banner dismissed state when thread changes
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
setRateLimitBannerDismissed(false)
|
|
201
|
+
}, [selectedThreadId])
|
|
146
202
|
|
|
147
203
|
const modelOptions = models.map((model): SelectOption => ({
|
|
148
204
|
value: model.id,
|
|
@@ -175,6 +231,18 @@ export function SessionView() {
|
|
|
175
231
|
description: approvalPolicyDescription(value),
|
|
176
232
|
})
|
|
177
233
|
)
|
|
234
|
+
const sandboxOptions: SelectOption[] = [
|
|
235
|
+
{
|
|
236
|
+
value: '',
|
|
237
|
+
label: 'Sandboxed',
|
|
238
|
+
description: 'Use configured sandbox settings',
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
value: 'danger-full-access',
|
|
242
|
+
label: 'Dangerously allow full access',
|
|
243
|
+
description: 'No filesystem sandbox (trusted only)',
|
|
244
|
+
},
|
|
245
|
+
]
|
|
178
246
|
const resumeCandidates = threadAccountId
|
|
179
247
|
? threads.filter((thread) => thread.accountId === threadAccountId)
|
|
180
248
|
: []
|
|
@@ -402,6 +470,7 @@ export function SessionView() {
|
|
|
402
470
|
summary: selectedSummary ?? null,
|
|
403
471
|
cwd: selectedCwd ?? null,
|
|
404
472
|
approvalPolicy: selectedApproval ?? null,
|
|
473
|
+
sandbox: selectedSandbox ?? null,
|
|
405
474
|
createdAt: Date.now(),
|
|
406
475
|
})
|
|
407
476
|
setInputValue('')
|
|
@@ -426,6 +495,79 @@ export function SessionView() {
|
|
|
426
495
|
try {
|
|
427
496
|
updateThread(selectedThreadId, { status: 'active' })
|
|
428
497
|
|
|
498
|
+
// Check if this thread has a pending account switch
|
|
499
|
+
// If so, we need to start a new thread on the new account
|
|
500
|
+
const pendingSwitch = threadPendingAccountSwitch[selectedThreadId]
|
|
501
|
+
let actualThreadId = selectedThreadId
|
|
502
|
+
|
|
503
|
+
if (pendingSwitch) {
|
|
504
|
+
// Start a new thread on the new account
|
|
505
|
+
const newAccountId = selectedThread.accountId
|
|
506
|
+
const accountModels = modelsByAccount[newAccountId] || []
|
|
507
|
+
const defaultThreadModel = accountModels.find((model) => model.isDefault) ?? accountModels[0]
|
|
508
|
+
const startParams: {
|
|
509
|
+
model?: string
|
|
510
|
+
approvalPolicy?: ApprovalPolicy
|
|
511
|
+
sandbox?: SandboxMode
|
|
512
|
+
config?: Record<string, unknown>
|
|
513
|
+
} = {}
|
|
514
|
+
if (defaultThreadModel?.id) {
|
|
515
|
+
startParams.model = defaultThreadModel.id
|
|
516
|
+
}
|
|
517
|
+
if (selectedApproval) {
|
|
518
|
+
startParams.approvalPolicy = selectedApproval
|
|
519
|
+
}
|
|
520
|
+
if (selectedSandbox) {
|
|
521
|
+
startParams.sandbox = selectedSandbox
|
|
522
|
+
} else if (selectedApproval === 'never') {
|
|
523
|
+
startParams.sandbox = ALWAYS_ALLOW_SANDBOX_MODE
|
|
524
|
+
}
|
|
525
|
+
if (selectedWebSearch) {
|
|
526
|
+
startParams.config = { 'features.web_search_request': true }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const result = (await hubClient.request(newAccountId, 'thread/start', startParams)) as {
|
|
530
|
+
thread?: {
|
|
531
|
+
id: string
|
|
532
|
+
preview?: string
|
|
533
|
+
modelProvider?: string
|
|
534
|
+
createdAt?: number
|
|
535
|
+
}
|
|
536
|
+
reasoningEffort?: ReasoningEffort | null
|
|
537
|
+
approvalPolicy?: ApprovalPolicy | null
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (!result.thread) {
|
|
541
|
+
throw new Error('Failed to start new thread on switched account')
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Use the new backend thread ID for this turn
|
|
545
|
+
actualThreadId = result.thread.id
|
|
546
|
+
|
|
547
|
+
// Map the backend thread ID to the UI thread ID so events get routed correctly
|
|
548
|
+
setBackendToUiThreadId(actualThreadId, selectedThreadId)
|
|
549
|
+
|
|
550
|
+
// Update the local thread to use the new backend thread ID
|
|
551
|
+
// We keep the same local thread but update its properties
|
|
552
|
+
updateThread(selectedThreadId, {
|
|
553
|
+
backendThreadId: actualThreadId,
|
|
554
|
+
status: 'active',
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
// Clear the pending switch flag
|
|
558
|
+
setThreadPendingAccountSwitch(selectedThreadId, null)
|
|
559
|
+
|
|
560
|
+
// Add a system message noting the new thread was created
|
|
561
|
+
addMessage(selectedThreadId, {
|
|
562
|
+
id: `sys-new-thread-${Date.now()}`,
|
|
563
|
+
role: 'assistant',
|
|
564
|
+
content: `New conversation started on this account. Previous context from ${pendingSwitch.previousAccountId} is shown for reference but not available to the model.`,
|
|
565
|
+
kind: 'tool',
|
|
566
|
+
title: 'New Conversation',
|
|
567
|
+
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
|
|
429
571
|
// Build input array with text, images, and file references
|
|
430
572
|
const input: Array<{ type: string; text?: string; url?: string; path?: string }> = []
|
|
431
573
|
|
|
@@ -443,7 +585,8 @@ export function SessionView() {
|
|
|
443
585
|
input.push({ type: 'image', url: attachment.url })
|
|
444
586
|
}
|
|
445
587
|
}
|
|
446
|
-
|
|
588
|
+
const sandboxPolicyOverride = resolveSandboxPolicy(selectedSandbox)
|
|
589
|
+
|
|
447
590
|
const params: {
|
|
448
591
|
threadId: string
|
|
449
592
|
input: Array<{ type: string; text?: string; url?: string; path?: string }>
|
|
@@ -452,8 +595,9 @@ export function SessionView() {
|
|
|
452
595
|
summary?: ReasoningSummary
|
|
453
596
|
cwd?: string
|
|
454
597
|
approvalPolicy?: ApprovalPolicy
|
|
598
|
+
sandboxPolicy?: SandboxPolicy
|
|
455
599
|
} = {
|
|
456
|
-
threadId:
|
|
600
|
+
threadId: actualThreadId,
|
|
457
601
|
input,
|
|
458
602
|
}
|
|
459
603
|
if (effectiveModel) {
|
|
@@ -465,6 +609,11 @@ export function SessionView() {
|
|
|
465
609
|
if (selectedApproval) {
|
|
466
610
|
params.approvalPolicy = selectedApproval
|
|
467
611
|
}
|
|
612
|
+
if (sandboxPolicyOverride) {
|
|
613
|
+
params.sandboxPolicy = sandboxPolicyOverride
|
|
614
|
+
} else if (selectedApproval === 'never') {
|
|
615
|
+
params.sandboxPolicy = ALWAYS_ALLOW_SANDBOX_POLICY
|
|
616
|
+
}
|
|
468
617
|
if (selectedSummary) {
|
|
469
618
|
params.summary = selectedSummary
|
|
470
619
|
}
|
|
@@ -487,13 +636,23 @@ export function SessionView() {
|
|
|
487
636
|
const startNewThread = async (accountId: string, approvalOverride?: ApprovalPolicy | null, webSearch?: boolean) => {
|
|
488
637
|
const accountModels = modelsByAccount[accountId] || []
|
|
489
638
|
const defaultThreadModel = accountModels.find((model) => model.isDefault) ?? accountModels[0]
|
|
490
|
-
const params: {
|
|
639
|
+
const params: {
|
|
640
|
+
model?: string
|
|
641
|
+
approvalPolicy?: ApprovalPolicy
|
|
642
|
+
sandbox?: SandboxMode
|
|
643
|
+
config?: Record<string, unknown>
|
|
644
|
+
} = {}
|
|
491
645
|
if (defaultThreadModel?.id) {
|
|
492
646
|
params.model = defaultThreadModel.id
|
|
493
647
|
}
|
|
494
648
|
if (approvalOverride) {
|
|
495
649
|
params.approvalPolicy = approvalOverride
|
|
496
650
|
}
|
|
651
|
+
if (selectedSandbox) {
|
|
652
|
+
params.sandbox = selectedSandbox
|
|
653
|
+
} else if (approvalOverride === 'never') {
|
|
654
|
+
params.sandbox = ALWAYS_ALLOW_SANDBOX_MODE
|
|
655
|
+
}
|
|
497
656
|
if (webSearch) {
|
|
498
657
|
params.config = { 'features.web_search_request': true }
|
|
499
658
|
}
|
|
@@ -542,6 +701,9 @@ export function SessionView() {
|
|
|
542
701
|
if (effort) {
|
|
543
702
|
setThreadEffort(threadId, effort)
|
|
544
703
|
}
|
|
704
|
+
if (selectedSandbox) {
|
|
705
|
+
setThreadSandbox(threadId, selectedSandbox)
|
|
706
|
+
}
|
|
545
707
|
if (selectedSummary) {
|
|
546
708
|
setThreadSummary(threadId, selectedSummary)
|
|
547
709
|
}
|
|
@@ -607,6 +769,7 @@ export function SessionView() {
|
|
|
607
769
|
`Reasoning summary: ${selectedSummary ?? 'default'}`,
|
|
608
770
|
`Working directory: ${selectedCwd || 'default'}`,
|
|
609
771
|
`Approvals: ${selectedApproval ?? 'default'}`,
|
|
772
|
+
`Sandbox: ${selectedSandbox ?? 'default'}`,
|
|
610
773
|
`Connection: ${connectionStatus}`,
|
|
611
774
|
]
|
|
612
775
|
if (usageLine) {
|
|
@@ -656,14 +819,14 @@ export function SessionView() {
|
|
|
656
819
|
}
|
|
657
820
|
|
|
658
821
|
const runReviewCommand = async (instructions?: string) => {
|
|
659
|
-
if (!selectedThreadId || !account) {
|
|
822
|
+
if (!selectedThreadId || !account || !effectiveBackendThreadId) {
|
|
660
823
|
return
|
|
661
824
|
}
|
|
662
825
|
const target = instructions
|
|
663
826
|
? { type: 'custom', instructions }
|
|
664
827
|
: { type: 'uncommittedChanges' }
|
|
665
828
|
await hubClient.request(account.id, 'review/start', {
|
|
666
|
-
threadId:
|
|
829
|
+
threadId: effectiveBackendThreadId,
|
|
667
830
|
target,
|
|
668
831
|
delivery: 'inline',
|
|
669
832
|
})
|
|
@@ -792,6 +955,36 @@ export function SessionView() {
|
|
|
792
955
|
setShowApprovalsDialog(true)
|
|
793
956
|
return
|
|
794
957
|
}
|
|
958
|
+
case 'switch': {
|
|
959
|
+
// If an account name is provided, try to switch directly
|
|
960
|
+
if (rest) {
|
|
961
|
+
const targetAccount = accounts.find(
|
|
962
|
+
(a) => a.name.toLowerCase() === rest.toLowerCase() ||
|
|
963
|
+
a.id === rest
|
|
964
|
+
)
|
|
965
|
+
if (targetAccount && targetAccount.status === 'online' && targetAccount.id !== threadAccountId) {
|
|
966
|
+
handleSwitchThreadAccount(targetAccount.id)
|
|
967
|
+
return
|
|
968
|
+
}
|
|
969
|
+
if (targetAccount && targetAccount.id === threadAccountId) {
|
|
970
|
+
addSystemMessage('tool', '/switch', `Already using account: ${targetAccount.name}`)
|
|
971
|
+
return
|
|
972
|
+
}
|
|
973
|
+
if (targetAccount && targetAccount.status !== 'online') {
|
|
974
|
+
addSystemMessage('tool', '/switch', `Account "${targetAccount.name}" is not authenticated.`)
|
|
975
|
+
return
|
|
976
|
+
}
|
|
977
|
+
addSystemMessage('tool', '/switch', `Account "${rest}" not found.`)
|
|
978
|
+
return
|
|
979
|
+
}
|
|
980
|
+
// Show dialog if no account specified
|
|
981
|
+
if (switchableAccounts.length === 0) {
|
|
982
|
+
addSystemMessage('tool', '/switch', 'No other authenticated accounts available to switch to.')
|
|
983
|
+
return
|
|
984
|
+
}
|
|
985
|
+
setShowAccountSwitchDialog(true)
|
|
986
|
+
return
|
|
987
|
+
}
|
|
795
988
|
case 'review': {
|
|
796
989
|
await runReviewCommand(rest || undefined)
|
|
797
990
|
return
|
|
@@ -966,12 +1159,12 @@ export function SessionView() {
|
|
|
966
1159
|
}
|
|
967
1160
|
|
|
968
1161
|
const handleArchive = async () => {
|
|
969
|
-
if (!selectedThreadId || !selectedThread || !canInteract) {
|
|
1162
|
+
if (!selectedThreadId || !selectedThread || !canInteract || !effectiveBackendThreadId) {
|
|
970
1163
|
return
|
|
971
1164
|
}
|
|
972
1165
|
try {
|
|
973
1166
|
await hubClient.request(selectedThread.accountId, 'thread/archive', {
|
|
974
|
-
threadId:
|
|
1167
|
+
threadId: effectiveBackendThreadId,
|
|
975
1168
|
})
|
|
976
1169
|
updateThread(selectedThreadId, { status: 'archived' })
|
|
977
1170
|
clearQueuedMessages(selectedThreadId)
|
|
@@ -1123,7 +1316,7 @@ export function SessionView() {
|
|
|
1123
1316
|
}
|
|
1124
1317
|
|
|
1125
1318
|
const handleInterruptTurn = async () => {
|
|
1126
|
-
if (!account || !selectedThreadId) {
|
|
1319
|
+
if (!account || !selectedThreadId || !effectiveBackendThreadId) {
|
|
1127
1320
|
return
|
|
1128
1321
|
}
|
|
1129
1322
|
const turnId = threadTurnIds[selectedThreadId]
|
|
@@ -1132,7 +1325,7 @@ export function SessionView() {
|
|
|
1132
1325
|
}
|
|
1133
1326
|
try {
|
|
1134
1327
|
await hubClient.request(account.id, 'turn/interrupt', {
|
|
1135
|
-
threadId:
|
|
1328
|
+
threadId: effectiveBackendThreadId,
|
|
1136
1329
|
turnId,
|
|
1137
1330
|
})
|
|
1138
1331
|
} catch {
|
|
@@ -1213,6 +1406,76 @@ export function SessionView() {
|
|
|
1213
1406
|
}
|
|
1214
1407
|
}
|
|
1215
1408
|
|
|
1409
|
+
const handleSwitchThreadAccount = async (newAccountId: string) => {
|
|
1410
|
+
if (!selectedThreadId || !selectedThread) {
|
|
1411
|
+
return
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const newAccount = accounts.find((a) => a.id === newAccountId)
|
|
1415
|
+
if (!newAccount || newAccount.status !== 'online') {
|
|
1416
|
+
setAlertDialog({
|
|
1417
|
+
open: true,
|
|
1418
|
+
title: 'Account Unavailable',
|
|
1419
|
+
message: 'Selected account is not authenticated. Please sign in first.',
|
|
1420
|
+
variant: 'warning',
|
|
1421
|
+
})
|
|
1422
|
+
return
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
const previousAccountId = selectedThread.accountId
|
|
1426
|
+
const previousAccount = accounts.find((a) => a.id === previousAccountId)
|
|
1427
|
+
|
|
1428
|
+
// If a task is running on the current account, interrupt it first
|
|
1429
|
+
if (isTaskRunning && previousAccount) {
|
|
1430
|
+
const turnId = threadTurnIds[selectedThreadId]
|
|
1431
|
+
if (turnId) {
|
|
1432
|
+
try {
|
|
1433
|
+
await hubClient.request(previousAccount.id, 'turn/interrupt', {
|
|
1434
|
+
threadId: effectiveBackendThreadId,
|
|
1435
|
+
turnId,
|
|
1436
|
+
})
|
|
1437
|
+
// Wait a moment for the interrupt to process
|
|
1438
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
1439
|
+
} catch {
|
|
1440
|
+
// Continue with switch even if interrupt fails
|
|
1441
|
+
console.warn('Failed to interrupt turn before account switch')
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
// Reset thread status
|
|
1445
|
+
updateThread(selectedThreadId, { status: 'idle' })
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Mark this thread as having a pending account switch
|
|
1449
|
+
// The next message sent will start a fresh conversation on the new account
|
|
1450
|
+
setThreadPendingAccountSwitch(selectedThreadId, {
|
|
1451
|
+
originalThreadId: selectedThreadId,
|
|
1452
|
+
previousAccountId,
|
|
1453
|
+
})
|
|
1454
|
+
|
|
1455
|
+
// Update thread's accountId
|
|
1456
|
+
updateThread(selectedThreadId, { accountId: newAccountId })
|
|
1457
|
+
|
|
1458
|
+
// Reset model selection to the new account's default model
|
|
1459
|
+
const newAccountModels = modelsByAccount[newAccountId] || []
|
|
1460
|
+
const newDefaultModel = newAccountModels.find((m) => m.isDefault) ?? newAccountModels[0]
|
|
1461
|
+
if (newDefaultModel) {
|
|
1462
|
+
setThreadModel(selectedThreadId, newDefaultModel.id)
|
|
1463
|
+
if (newDefaultModel.defaultReasoningEffort) {
|
|
1464
|
+
setThreadEffort(selectedThreadId, newDefaultModel.defaultReasoningEffort)
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// Add a system message to indicate the switch
|
|
1469
|
+
addMessage(selectedThreadId, {
|
|
1470
|
+
id: `sys-switch-${Date.now()}`,
|
|
1471
|
+
role: 'assistant',
|
|
1472
|
+
content: `Switched to account: ${newAccount.name}. Your next message will start a new conversation using this account.`,
|
|
1473
|
+
kind: 'tool',
|
|
1474
|
+
title: 'Account Switch',
|
|
1475
|
+
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
|
1476
|
+
})
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1216
1479
|
if (!selectedThread) {
|
|
1217
1480
|
return <SessionEmpty onNewSession={handleEmptyNewSession} />
|
|
1218
1481
|
}
|
|
@@ -1221,11 +1484,14 @@ export function SessionView() {
|
|
|
1221
1484
|
<main className="flex-1 flex flex-col h-full bg-bg-primary overflow-hidden">
|
|
1222
1485
|
<SessionHeader
|
|
1223
1486
|
title={selectedThread.title}
|
|
1487
|
+
accountId={selectedThread.accountId}
|
|
1224
1488
|
accountName={account?.name}
|
|
1489
|
+
accounts={accounts}
|
|
1225
1490
|
model={selectedThread.model}
|
|
1226
1491
|
status={selectedThread.status}
|
|
1227
1492
|
canInteract={canInteract}
|
|
1228
1493
|
onArchive={handleArchive}
|
|
1494
|
+
onSwitchAccount={handleSwitchThreadAccount}
|
|
1229
1495
|
/>
|
|
1230
1496
|
<SessionAuthBanner
|
|
1231
1497
|
visible={!isAccountReady}
|
|
@@ -1235,6 +1501,14 @@ export function SessionView() {
|
|
|
1235
1501
|
onCancel={account?.id && accountLoginIds[account.id] ? handleCancelAuth : undefined}
|
|
1236
1502
|
onRefresh={account ? () => void refreshAccountStatus(account.id) : undefined}
|
|
1237
1503
|
/>
|
|
1504
|
+
<RateLimitBanner
|
|
1505
|
+
visible={showRateLimitBanner}
|
|
1506
|
+
currentAccount={account}
|
|
1507
|
+
availableAccounts={switchableAccounts}
|
|
1508
|
+
errorMessage={rateLimitError ?? undefined}
|
|
1509
|
+
onSwitchAccount={handleSwitchThreadAccount}
|
|
1510
|
+
onDismiss={() => setRateLimitBannerDismissed(true)}
|
|
1511
|
+
/>
|
|
1238
1512
|
<VirtualizedMessageList
|
|
1239
1513
|
messages={threadMessages}
|
|
1240
1514
|
approvals={pendingApprovals}
|
|
@@ -1298,6 +1572,17 @@ export function SessionView() {
|
|
|
1298
1572
|
}}
|
|
1299
1573
|
showModelSelect={models.length > 0}
|
|
1300
1574
|
showEffortSelect={effortOptions.length > 0}
|
|
1575
|
+
sandboxOptions={sandboxOptions}
|
|
1576
|
+
effectiveSandbox={selectedSandbox ?? ''}
|
|
1577
|
+
onSandboxChange={(value) => {
|
|
1578
|
+
if (!selectedThreadId) return
|
|
1579
|
+
if (!value) {
|
|
1580
|
+
setThreadSandbox(selectedThreadId, null)
|
|
1581
|
+
return
|
|
1582
|
+
}
|
|
1583
|
+
setThreadSandbox(selectedThreadId, value as SandboxMode)
|
|
1584
|
+
}}
|
|
1585
|
+
showSandboxSelect
|
|
1301
1586
|
queuedCount={queuedCount}
|
|
1302
1587
|
webSearchEnabled={webSearchEnabled}
|
|
1303
1588
|
onWebSearchToggle={() => {
|
|
@@ -1347,6 +1632,11 @@ export function SessionView() {
|
|
|
1347
1632
|
onCloseResumeDialog={() => setShowResumeDialog(false)}
|
|
1348
1633
|
resumeCandidates={resumeCandidates}
|
|
1349
1634
|
onResumeThread={handleResumeThread}
|
|
1635
|
+
showAccountSwitchDialog={showAccountSwitchDialog}
|
|
1636
|
+
onCloseAccountSwitchDialog={() => setShowAccountSwitchDialog(false)}
|
|
1637
|
+
currentAccount={account}
|
|
1638
|
+
switchableAccounts={switchableAccounts}
|
|
1639
|
+
onSwitchAccount={handleSwitchThreadAccount}
|
|
1350
1640
|
showFeedbackDialog={showFeedbackDialog}
|
|
1351
1641
|
onCloseFeedbackDialog={() => setShowFeedbackDialog(false)}
|
|
1352
1642
|
feedbackCategory={feedbackCategory}
|