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.
@@ -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: selectedThreadId,
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: { model?: string; approvalPolicy?: ApprovalPolicy; config?: Record<string, unknown> } = {}
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: selectedThreadId,
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: selectedThreadId,
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: selectedThreadId,
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}