better-codex 0.2.1 → 0.2.3
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/server.ts +19 -0
- package/apps/web/src/components/layout/session-view.tsx +100 -5
- package/apps/web/src/components/layout/side-bar.tsx +266 -50
- package/apps/web/src/components/layout/virtualized-message-list.tsx +28 -6
- 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/ui/icons.tsx +6 -0
- package/apps/web/src/hooks/use-hub-connection.ts +23 -1
- package/apps/web/src/hooks/use-thread-history.ts +26 -1
- package/apps/web/src/services/hub-client.ts +14 -1
- package/apps/web/src/store/index.ts +42 -7
- package/apps/web/src/types/index.ts +6 -0
- package/apps/web/src/utils/account-refresh.ts +9 -0
- package/bin/better-codex.cjs +5 -14
- package/package.json +1 -1
|
@@ -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 }) => {
|
|
@@ -3,7 +3,7 @@ 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'
|
|
@@ -17,6 +17,22 @@ import { SessionDialogs } from '../session-view/session-dialogs'
|
|
|
17
17
|
import { SessionEmpty } from '../session-view/session-empty'
|
|
18
18
|
import { RateLimitBanner, isRateLimitError } from '../session-view/rate-limit-banner'
|
|
19
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
|
+
}
|
|
35
|
+
|
|
20
36
|
export function SessionView() {
|
|
21
37
|
const [inputValue, setInputValue] = useState('')
|
|
22
38
|
const [slashIndex, setSlashIndex] = useState(0)
|
|
@@ -76,6 +92,7 @@ export function SessionView() {
|
|
|
76
92
|
threadEfforts,
|
|
77
93
|
threadApprovals,
|
|
78
94
|
threadWebSearch,
|
|
95
|
+
threadSandboxes,
|
|
79
96
|
threadTurnIds,
|
|
80
97
|
threadTurnStartedAt,
|
|
81
98
|
threadLastTurnDuration,
|
|
@@ -85,6 +102,7 @@ export function SessionView() {
|
|
|
85
102
|
setThreadEffort,
|
|
86
103
|
setThreadApproval,
|
|
87
104
|
setThreadWebSearch,
|
|
105
|
+
setThreadSandbox,
|
|
88
106
|
threadSummaries,
|
|
89
107
|
setThreadSummary,
|
|
90
108
|
threadCwds,
|
|
@@ -121,6 +139,7 @@ export function SessionView() {
|
|
|
121
139
|
const selectedEffort = selectedThreadId ? threadEfforts[selectedThreadId] : undefined
|
|
122
140
|
const effectiveEffort = selectedEffort ?? defaultEffort ?? null
|
|
123
141
|
const selectedApproval = selectedThreadId ? threadApprovals[selectedThreadId] : undefined
|
|
142
|
+
const selectedSandbox = selectedThreadId ? threadSandboxes[selectedThreadId] : undefined
|
|
124
143
|
const selectedSummary = selectedThreadId ? threadSummaries[selectedThreadId] : undefined
|
|
125
144
|
const selectedCwd = selectedThreadId ? threadCwds[selectedThreadId] : undefined
|
|
126
145
|
const selectedUsage = selectedThreadId ? threadTokenUsage[selectedThreadId] : undefined
|
|
@@ -212,6 +231,18 @@ export function SessionView() {
|
|
|
212
231
|
description: approvalPolicyDescription(value),
|
|
213
232
|
})
|
|
214
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
|
+
]
|
|
215
246
|
const resumeCandidates = threadAccountId
|
|
216
247
|
? threads.filter((thread) => thread.accountId === threadAccountId)
|
|
217
248
|
: []
|
|
@@ -439,6 +470,7 @@ export function SessionView() {
|
|
|
439
470
|
summary: selectedSummary ?? null,
|
|
440
471
|
cwd: selectedCwd ?? null,
|
|
441
472
|
approvalPolicy: selectedApproval ?? null,
|
|
473
|
+
sandbox: selectedSandbox ?? null,
|
|
442
474
|
createdAt: Date.now(),
|
|
443
475
|
})
|
|
444
476
|
setInputValue('')
|
|
@@ -473,13 +505,23 @@ export function SessionView() {
|
|
|
473
505
|
const newAccountId = selectedThread.accountId
|
|
474
506
|
const accountModels = modelsByAccount[newAccountId] || []
|
|
475
507
|
const defaultThreadModel = accountModels.find((model) => model.isDefault) ?? accountModels[0]
|
|
476
|
-
const startParams: {
|
|
508
|
+
const startParams: {
|
|
509
|
+
model?: string
|
|
510
|
+
approvalPolicy?: ApprovalPolicy
|
|
511
|
+
sandbox?: SandboxMode
|
|
512
|
+
config?: Record<string, unknown>
|
|
513
|
+
} = {}
|
|
477
514
|
if (defaultThreadModel?.id) {
|
|
478
515
|
startParams.model = defaultThreadModel.id
|
|
479
516
|
}
|
|
480
517
|
if (selectedApproval) {
|
|
481
518
|
startParams.approvalPolicy = selectedApproval
|
|
482
519
|
}
|
|
520
|
+
if (selectedSandbox) {
|
|
521
|
+
startParams.sandbox = selectedSandbox
|
|
522
|
+
} else if (selectedApproval === 'never') {
|
|
523
|
+
startParams.sandbox = ALWAYS_ALLOW_SANDBOX_MODE
|
|
524
|
+
}
|
|
483
525
|
if (selectedWebSearch) {
|
|
484
526
|
startParams.config = { 'features.web_search_request': true }
|
|
485
527
|
}
|
|
@@ -543,7 +585,8 @@ export function SessionView() {
|
|
|
543
585
|
input.push({ type: 'image', url: attachment.url })
|
|
544
586
|
}
|
|
545
587
|
}
|
|
546
|
-
|
|
588
|
+
const sandboxPolicyOverride = resolveSandboxPolicy(selectedSandbox)
|
|
589
|
+
|
|
547
590
|
const params: {
|
|
548
591
|
threadId: string
|
|
549
592
|
input: Array<{ type: string; text?: string; url?: string; path?: string }>
|
|
@@ -552,6 +595,7 @@ export function SessionView() {
|
|
|
552
595
|
summary?: ReasoningSummary
|
|
553
596
|
cwd?: string
|
|
554
597
|
approvalPolicy?: ApprovalPolicy
|
|
598
|
+
sandboxPolicy?: SandboxPolicy
|
|
555
599
|
} = {
|
|
556
600
|
threadId: actualThreadId,
|
|
557
601
|
input,
|
|
@@ -565,6 +609,11 @@ export function SessionView() {
|
|
|
565
609
|
if (selectedApproval) {
|
|
566
610
|
params.approvalPolicy = selectedApproval
|
|
567
611
|
}
|
|
612
|
+
if (sandboxPolicyOverride) {
|
|
613
|
+
params.sandboxPolicy = sandboxPolicyOverride
|
|
614
|
+
} else if (selectedApproval === 'never') {
|
|
615
|
+
params.sandboxPolicy = ALWAYS_ALLOW_SANDBOX_POLICY
|
|
616
|
+
}
|
|
568
617
|
if (selectedSummary) {
|
|
569
618
|
params.summary = selectedSummary
|
|
570
619
|
}
|
|
@@ -587,13 +636,23 @@ export function SessionView() {
|
|
|
587
636
|
const startNewThread = async (accountId: string, approvalOverride?: ApprovalPolicy | null, webSearch?: boolean) => {
|
|
588
637
|
const accountModels = modelsByAccount[accountId] || []
|
|
589
638
|
const defaultThreadModel = accountModels.find((model) => model.isDefault) ?? accountModels[0]
|
|
590
|
-
const params: {
|
|
639
|
+
const params: {
|
|
640
|
+
model?: string
|
|
641
|
+
approvalPolicy?: ApprovalPolicy
|
|
642
|
+
sandbox?: SandboxMode
|
|
643
|
+
config?: Record<string, unknown>
|
|
644
|
+
} = {}
|
|
591
645
|
if (defaultThreadModel?.id) {
|
|
592
646
|
params.model = defaultThreadModel.id
|
|
593
647
|
}
|
|
594
648
|
if (approvalOverride) {
|
|
595
649
|
params.approvalPolicy = approvalOverride
|
|
596
650
|
}
|
|
651
|
+
if (selectedSandbox) {
|
|
652
|
+
params.sandbox = selectedSandbox
|
|
653
|
+
} else if (approvalOverride === 'never') {
|
|
654
|
+
params.sandbox = ALWAYS_ALLOW_SANDBOX_MODE
|
|
655
|
+
}
|
|
597
656
|
if (webSearch) {
|
|
598
657
|
params.config = { 'features.web_search_request': true }
|
|
599
658
|
}
|
|
@@ -642,6 +701,9 @@ export function SessionView() {
|
|
|
642
701
|
if (effort) {
|
|
643
702
|
setThreadEffort(threadId, effort)
|
|
644
703
|
}
|
|
704
|
+
if (selectedSandbox) {
|
|
705
|
+
setThreadSandbox(threadId, selectedSandbox)
|
|
706
|
+
}
|
|
645
707
|
if (selectedSummary) {
|
|
646
708
|
setThreadSummary(threadId, selectedSummary)
|
|
647
709
|
}
|
|
@@ -707,6 +769,7 @@ export function SessionView() {
|
|
|
707
769
|
`Reasoning summary: ${selectedSummary ?? 'default'}`,
|
|
708
770
|
`Working directory: ${selectedCwd || 'default'}`,
|
|
709
771
|
`Approvals: ${selectedApproval ?? 'default'}`,
|
|
772
|
+
`Sandbox: ${selectedSandbox ?? 'default'}`,
|
|
710
773
|
`Connection: ${connectionStatus}`,
|
|
711
774
|
]
|
|
712
775
|
if (usageLine) {
|
|
@@ -1343,7 +1406,7 @@ export function SessionView() {
|
|
|
1343
1406
|
}
|
|
1344
1407
|
}
|
|
1345
1408
|
|
|
1346
|
-
const handleSwitchThreadAccount = (newAccountId: string) => {
|
|
1409
|
+
const handleSwitchThreadAccount = async (newAccountId: string) => {
|
|
1347
1410
|
if (!selectedThreadId || !selectedThread) {
|
|
1348
1411
|
return
|
|
1349
1412
|
}
|
|
@@ -1360,6 +1423,27 @@ export function SessionView() {
|
|
|
1360
1423
|
}
|
|
1361
1424
|
|
|
1362
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
|
+
}
|
|
1363
1447
|
|
|
1364
1448
|
// Mark this thread as having a pending account switch
|
|
1365
1449
|
// The next message sent will start a fresh conversation on the new account
|
|
@@ -1488,6 +1572,17 @@ export function SessionView() {
|
|
|
1488
1572
|
}}
|
|
1489
1573
|
showModelSelect={models.length > 0}
|
|
1490
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
|
|
1491
1586
|
queuedCount={queuedCount}
|
|
1492
1587
|
webSearchEnabled={webSearchEnabled}
|
|
1493
1588
|
onWebSearchToggle={() => {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import { useAppStore } from '../../store'
|
|
3
3
|
import { hubClient } from '../../services/hub-client'
|
|
4
|
-
import {
|
|
4
|
+
import { refreshAccountSnapshot } from '../../utils/account-refresh'
|
|
5
|
+
import { Avatar, Button, Dialog, IconButton, Icons, SectionHeader, AlertDialog, CopyDialog } from '../ui'
|
|
5
6
|
import { AccountUsagePanel } from './account-usage-panel'
|
|
6
7
|
import { SettingsDialog } from './settings-dialog'
|
|
7
8
|
|
|
@@ -9,13 +10,24 @@ interface SidebarProps {
|
|
|
9
10
|
onNavigate?: () => void
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
type AuthMethod = 'chatgpt' | 'apiKey'
|
|
14
|
+
|
|
12
15
|
export function Sidebar({ onNavigate }: SidebarProps) {
|
|
13
16
|
const [isAdding, setIsAdding] = useState(false)
|
|
14
17
|
const [isRemoving, setIsRemoving] = useState(false)
|
|
15
|
-
const [
|
|
18
|
+
const [showAddDialog, setShowAddDialog] = useState(false)
|
|
16
19
|
const [showUsage, setShowUsage] = useState(false)
|
|
17
20
|
const [showSettings, setShowSettings] = useState(false)
|
|
18
21
|
const [authPendingId, setAuthPendingId] = useState<string | null>(null)
|
|
22
|
+
const [authDialog, setAuthDialog] = useState<{
|
|
23
|
+
open: boolean
|
|
24
|
+
mode: 'create' | 'login'
|
|
25
|
+
accountId?: string
|
|
26
|
+
accountName?: string
|
|
27
|
+
}>({ open: false, mode: 'create' })
|
|
28
|
+
const [authMethod, setAuthMethod] = useState<AuthMethod>('chatgpt')
|
|
29
|
+
const [authApiKey, setAuthApiKey] = useState('')
|
|
30
|
+
const [newAccountName, setNewAccountName] = useState('')
|
|
19
31
|
const [removeDialog, setRemoveDialog] = useState<{ open: boolean; accountId: string; name: string }>({
|
|
20
32
|
open: false,
|
|
21
33
|
accountId: '',
|
|
@@ -43,6 +55,8 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|
|
43
55
|
setShowAnalytics,
|
|
44
56
|
showReviews,
|
|
45
57
|
setShowReviews,
|
|
58
|
+
setAccountLoginId,
|
|
59
|
+
setModelsForAccount,
|
|
46
60
|
} = useAppStore()
|
|
47
61
|
|
|
48
62
|
const getStatusColor = (status: 'online' | 'degraded' | 'offline') => {
|
|
@@ -53,35 +67,51 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|
|
53
67
|
}
|
|
54
68
|
}
|
|
55
69
|
|
|
56
|
-
const
|
|
57
|
-
|
|
70
|
+
const resetAuthDialog = () => {
|
|
71
|
+
setAuthMethod('chatgpt')
|
|
72
|
+
setAuthApiKey('')
|
|
73
|
+
setNewAccountName('')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const handleAccountAuth = async (accountId: string, method: AuthMethod, apiKey?: string): Promise<boolean> => {
|
|
77
|
+
if (connectionStatus !== 'connected' || !hubClient.isConnected()) {
|
|
58
78
|
setAlertDialog({
|
|
59
79
|
open: true,
|
|
60
80
|
title: 'Not Connected',
|
|
61
81
|
message: 'Backend not connected. Start the hub and refresh the page.',
|
|
62
82
|
variant: 'error',
|
|
63
83
|
})
|
|
64
|
-
return
|
|
84
|
+
return false
|
|
65
85
|
}
|
|
66
86
|
setAuthPendingId(accountId)
|
|
67
87
|
updateAccount(accountId, (prev) => ({ ...prev, status: 'degraded' }))
|
|
68
88
|
try {
|
|
69
89
|
const login = (await hubClient.request(accountId, 'account/login/start', {
|
|
70
|
-
type:
|
|
71
|
-
|
|
90
|
+
type: method,
|
|
91
|
+
apiKey: method === 'apiKey' ? apiKey : undefined,
|
|
92
|
+
})) as { authUrl?: string; loginId?: string }
|
|
93
|
+
if (login?.loginId) {
|
|
94
|
+
setAccountLoginId(accountId, login.loginId)
|
|
95
|
+
}
|
|
72
96
|
if (login?.authUrl) {
|
|
73
97
|
const opened = window.open(login.authUrl, '_blank', 'noopener,noreferrer')
|
|
74
98
|
if (!opened) {
|
|
75
99
|
setCopyDialog({ open: true, url: login.authUrl })
|
|
76
100
|
}
|
|
77
101
|
}
|
|
102
|
+
if (method === 'apiKey') {
|
|
103
|
+
setAccountLoginId(accountId, null)
|
|
104
|
+
await refreshAccountSnapshot(accountId, updateAccount, setModelsForAccount)
|
|
105
|
+
}
|
|
106
|
+
return true
|
|
78
107
|
} catch {
|
|
79
108
|
setAlertDialog({
|
|
80
109
|
open: true,
|
|
81
110
|
title: 'Sign In Failed',
|
|
82
|
-
message:
|
|
111
|
+
message: `Unable to start ${method === 'apiKey' ? 'API key' : 'ChatGPT'} sign-in. Please try again.`,
|
|
83
112
|
variant: 'error',
|
|
84
113
|
})
|
|
114
|
+
return false
|
|
85
115
|
} finally {
|
|
86
116
|
setAuthPendingId(null)
|
|
87
117
|
}
|
|
@@ -145,7 +175,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|
|
145
175
|
type="button"
|
|
146
176
|
onClick={(e) => {
|
|
147
177
|
e.stopPropagation()
|
|
148
|
-
|
|
178
|
+
setAuthDialog({
|
|
179
|
+
open: true,
|
|
180
|
+
mode: 'login',
|
|
181
|
+
accountId: account.id,
|
|
182
|
+
accountName: account.name,
|
|
183
|
+
})
|
|
184
|
+
resetAuthDialog()
|
|
149
185
|
}}
|
|
150
186
|
disabled={authPendingId === account.id}
|
|
151
187
|
className="text-[10px] text-accent-green hover:text-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
@@ -209,56 +245,236 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|
|
209
245
|
})
|
|
210
246
|
return
|
|
211
247
|
}
|
|
212
|
-
|
|
248
|
+
setShowAddDialog(true)
|
|
249
|
+
resetAuthDialog()
|
|
213
250
|
}}
|
|
214
251
|
>
|
|
215
252
|
<Icons.Plus className="w-3.5 h-3.5" />
|
|
216
253
|
Add Account
|
|
217
254
|
</button>
|
|
218
255
|
|
|
219
|
-
<
|
|
220
|
-
open={
|
|
221
|
-
onClose={() =>
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
256
|
+
<Dialog
|
|
257
|
+
open={showAddDialog}
|
|
258
|
+
onClose={() => {
|
|
259
|
+
setShowAddDialog(false)
|
|
260
|
+
resetAuthDialog()
|
|
261
|
+
}}
|
|
262
|
+
title="Add Account"
|
|
263
|
+
>
|
|
264
|
+
<form
|
|
265
|
+
onSubmit={async (event) => {
|
|
266
|
+
event.preventDefault()
|
|
267
|
+
if (isAdding) return
|
|
268
|
+
const name = newAccountName.trim()
|
|
269
|
+
if (!name) return
|
|
270
|
+
if (authMethod === 'apiKey' && !authApiKey.trim()) return
|
|
271
|
+
setIsAdding(true)
|
|
272
|
+
try {
|
|
273
|
+
const profile = await hubClient.createProfile(name)
|
|
274
|
+
addAccount({
|
|
275
|
+
id: profile.id,
|
|
276
|
+
name: profile.name,
|
|
277
|
+
email: '',
|
|
278
|
+
plan: 'Unknown',
|
|
279
|
+
status: 'offline',
|
|
280
|
+
rateLimit: 0,
|
|
281
|
+
})
|
|
282
|
+
setSelectedAccountId(profile.id)
|
|
283
|
+
await hubClient.startProfile(profile.id)
|
|
284
|
+
updateAccount(profile.id, (prev) => ({ ...prev, status: 'degraded' }))
|
|
285
|
+
const success = await handleAccountAuth(profile.id, authMethod, authApiKey.trim() || undefined)
|
|
286
|
+
if (success) {
|
|
287
|
+
setShowAddDialog(false)
|
|
245
288
|
}
|
|
289
|
+
} catch {
|
|
290
|
+
setAlertDialog({
|
|
291
|
+
open: true,
|
|
292
|
+
title: 'Error',
|
|
293
|
+
message: 'Failed to create account. Please try again.',
|
|
294
|
+
variant: 'error',
|
|
295
|
+
})
|
|
296
|
+
} finally {
|
|
297
|
+
setIsAdding(false)
|
|
246
298
|
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
<div className="space-y-4">
|
|
302
|
+
<div className="space-y-1">
|
|
303
|
+
<div className="text-[10px] uppercase tracking-[0.2em] text-text-muted">Account name</div>
|
|
304
|
+
<input
|
|
305
|
+
value={newAccountName}
|
|
306
|
+
onChange={(event) => setNewAccountName(event.target.value)}
|
|
307
|
+
placeholder="Personal, Team, or Client..."
|
|
308
|
+
className="w-full bg-bg-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary placeholder:text-text-muted outline-none focus:border-text-muted transition-colors"
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
<div className="space-y-2">
|
|
312
|
+
<div className="text-[10px] uppercase tracking-[0.2em] text-text-muted">Sign-in method</div>
|
|
313
|
+
<div className="grid grid-cols-1 gap-2">
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
onClick={() => setAuthMethod('chatgpt')}
|
|
317
|
+
className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
|
|
318
|
+
authMethod === 'chatgpt'
|
|
319
|
+
? 'border-accent-green bg-accent-green/10 text-text-primary'
|
|
320
|
+
: 'border-border bg-bg-tertiary text-text-secondary hover:bg-bg-hover'
|
|
321
|
+
}`}
|
|
322
|
+
>
|
|
323
|
+
<div>
|
|
324
|
+
<div className="text-xs font-semibold">ChatGPT sign-in</div>
|
|
325
|
+
<div className="text-[10px] text-text-muted">Opens a browser window to authorize.</div>
|
|
326
|
+
</div>
|
|
327
|
+
<Icons.ArrowRight className="w-4 h-4" />
|
|
328
|
+
</button>
|
|
329
|
+
<button
|
|
330
|
+
type="button"
|
|
331
|
+
onClick={() => setAuthMethod('apiKey')}
|
|
332
|
+
className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
|
|
333
|
+
authMethod === 'apiKey'
|
|
334
|
+
? 'border-accent-blue bg-accent-blue/10 text-text-primary'
|
|
335
|
+
: 'border-border bg-bg-tertiary text-text-secondary hover:bg-bg-hover'
|
|
336
|
+
}`}
|
|
337
|
+
>
|
|
338
|
+
<div>
|
|
339
|
+
<div className="text-xs font-semibold">API key</div>
|
|
340
|
+
<div className="text-[10px] text-text-muted">Use a key instead of browser auth.</div>
|
|
341
|
+
</div>
|
|
342
|
+
<Icons.Key className="w-4 h-4" />
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
{authMethod === 'apiKey' && (
|
|
347
|
+
<div className="space-y-1">
|
|
348
|
+
<div className="text-[10px] uppercase tracking-[0.2em] text-text-muted">API key</div>
|
|
349
|
+
<input
|
|
350
|
+
type="password"
|
|
351
|
+
value={authApiKey}
|
|
352
|
+
onChange={(event) => setAuthApiKey(event.target.value)}
|
|
353
|
+
placeholder="sk-..."
|
|
354
|
+
className="w-full bg-bg-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary placeholder:text-text-muted outline-none focus:border-text-muted transition-colors"
|
|
355
|
+
/>
|
|
356
|
+
<p className="text-[11px] text-text-muted">Stored locally by the hub.</p>
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
<div className="flex justify-end gap-2 mt-5">
|
|
361
|
+
<Button
|
|
362
|
+
variant="ghost"
|
|
363
|
+
size="sm"
|
|
364
|
+
onClick={() => setShowAddDialog(false)}
|
|
365
|
+
>
|
|
366
|
+
Cancel
|
|
367
|
+
</Button>
|
|
368
|
+
<Button
|
|
369
|
+
variant="primary"
|
|
370
|
+
size="sm"
|
|
371
|
+
disabled={
|
|
372
|
+
isAdding ||
|
|
373
|
+
!newAccountName.trim() ||
|
|
374
|
+
(authMethod === 'apiKey' && !authApiKey.trim())
|
|
375
|
+
}
|
|
376
|
+
>
|
|
377
|
+
{isAdding ? 'Creating...' : 'Create & Connect'}
|
|
378
|
+
</Button>
|
|
379
|
+
</div>
|
|
380
|
+
</form>
|
|
381
|
+
</Dialog>
|
|
382
|
+
|
|
383
|
+
<Dialog
|
|
384
|
+
open={authDialog.open}
|
|
385
|
+
onClose={() => {
|
|
386
|
+
setAuthDialog({ open: false, mode: 'create' })
|
|
387
|
+
resetAuthDialog()
|
|
257
388
|
}}
|
|
258
|
-
title=
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
389
|
+
title={`Sign in ${authDialog.accountName ? `• ${authDialog.accountName}` : ''}`}
|
|
390
|
+
>
|
|
391
|
+
<form
|
|
392
|
+
onSubmit={async (event) => {
|
|
393
|
+
event.preventDefault()
|
|
394
|
+
if (!authDialog.accountId) return
|
|
395
|
+
if (authMethod === 'apiKey' && !authApiKey.trim()) return
|
|
396
|
+
const success = await handleAccountAuth(
|
|
397
|
+
authDialog.accountId,
|
|
398
|
+
authMethod,
|
|
399
|
+
authApiKey.trim() || undefined
|
|
400
|
+
)
|
|
401
|
+
if (success) {
|
|
402
|
+
setAuthDialog({ open: false, mode: 'create' })
|
|
403
|
+
}
|
|
404
|
+
}}
|
|
405
|
+
>
|
|
406
|
+
<div className="space-y-4">
|
|
407
|
+
<div className="text-xs text-text-muted">
|
|
408
|
+
Choose how you want to authenticate this account. ChatGPT is the fastest option, but API keys work offline.
|
|
409
|
+
</div>
|
|
410
|
+
<div className="grid grid-cols-1 gap-2">
|
|
411
|
+
<button
|
|
412
|
+
type="button"
|
|
413
|
+
onClick={() => setAuthMethod('chatgpt')}
|
|
414
|
+
className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
|
|
415
|
+
authMethod === 'chatgpt'
|
|
416
|
+
? 'border-accent-green bg-accent-green/10 text-text-primary'
|
|
417
|
+
: 'border-border bg-bg-tertiary text-text-secondary hover:bg-bg-hover'
|
|
418
|
+
}`}
|
|
419
|
+
>
|
|
420
|
+
<div>
|
|
421
|
+
<div className="text-xs font-semibold">ChatGPT sign-in</div>
|
|
422
|
+
<div className="text-[10px] text-text-muted">Opens a browser window to authorize.</div>
|
|
423
|
+
</div>
|
|
424
|
+
<Icons.ArrowRight className="w-4 h-4" />
|
|
425
|
+
</button>
|
|
426
|
+
<button
|
|
427
|
+
type="button"
|
|
428
|
+
onClick={() => setAuthMethod('apiKey')}
|
|
429
|
+
className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
|
|
430
|
+
authMethod === 'apiKey'
|
|
431
|
+
? 'border-accent-blue bg-accent-blue/10 text-text-primary'
|
|
432
|
+
: 'border-border bg-bg-tertiary text-text-secondary hover:bg-bg-hover'
|
|
433
|
+
}`}
|
|
434
|
+
>
|
|
435
|
+
<div>
|
|
436
|
+
<div className="text-xs font-semibold">API key</div>
|
|
437
|
+
<div className="text-[10px] text-text-muted">Use a key instead of browser auth.</div>
|
|
438
|
+
</div>
|
|
439
|
+
<Icons.Key className="w-4 h-4" />
|
|
440
|
+
</button>
|
|
441
|
+
</div>
|
|
442
|
+
{authMethod === 'apiKey' && (
|
|
443
|
+
<div className="space-y-1">
|
|
444
|
+
<div className="text-[10px] uppercase tracking-[0.2em] text-text-muted">API key</div>
|
|
445
|
+
<input
|
|
446
|
+
type="password"
|
|
447
|
+
value={authApiKey}
|
|
448
|
+
onChange={(event) => setAuthApiKey(event.target.value)}
|
|
449
|
+
placeholder="sk-..."
|
|
450
|
+
className="w-full bg-bg-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary placeholder:text-text-muted outline-none focus:border-text-muted transition-colors"
|
|
451
|
+
/>
|
|
452
|
+
<p className="text-[11px] text-text-muted">Stored locally by the hub.</p>
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
<div className="flex justify-end gap-2 mt-5">
|
|
457
|
+
<Button
|
|
458
|
+
variant="ghost"
|
|
459
|
+
size="sm"
|
|
460
|
+
onClick={() => setAuthDialog({ open: false, mode: 'create' })}
|
|
461
|
+
>
|
|
462
|
+
Cancel
|
|
463
|
+
</Button>
|
|
464
|
+
<Button
|
|
465
|
+
variant="primary"
|
|
466
|
+
size="sm"
|
|
467
|
+
disabled={
|
|
468
|
+
!authDialog.accountId ||
|
|
469
|
+
(authMethod === 'apiKey' && !authApiKey.trim()) ||
|
|
470
|
+
authPendingId === authDialog.accountId
|
|
471
|
+
}
|
|
472
|
+
>
|
|
473
|
+
{authPendingId === authDialog.accountId ? 'Connecting...' : 'Connect'}
|
|
474
|
+
</Button>
|
|
475
|
+
</div>
|
|
476
|
+
</form>
|
|
477
|
+
</Dialog>
|
|
262
478
|
|
|
263
479
|
<AlertDialog
|
|
264
480
|
open={alertDialog.open}
|
|
@@ -379,7 +379,7 @@ function getActionType(msg: Message): AssistantAction['type'] {
|
|
|
379
379
|
if (title.includes('read') || title.includes('view') || title.includes('list')) return 'explored'
|
|
380
380
|
if (title.includes('edit') || title.includes('wrote') || title.includes('creat')) return 'edited'
|
|
381
381
|
if (title.includes('ran') || title.includes('exec') || title.includes('command')) return 'ran'
|
|
382
|
-
return '
|
|
382
|
+
return 'chat'
|
|
383
383
|
}
|
|
384
384
|
return 'chat'
|
|
385
385
|
}
|
|
@@ -444,15 +444,31 @@ function getActionLabel(type: AssistantAction['type'], messages: Message[]): { l
|
|
|
444
444
|
}
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
const extractBoldHeadline = (content: string): string | null => {
|
|
448
|
+
const match = content.match(/\*\*(.+?)\*\*/)
|
|
449
|
+
return match ? match[1] : null
|
|
450
|
+
}
|
|
451
|
+
|
|
447
452
|
const extractReasoningHeadline = (content: string) => {
|
|
448
|
-
const
|
|
449
|
-
if (
|
|
450
|
-
return
|
|
453
|
+
const boldHeadline = extractBoldHeadline(content)
|
|
454
|
+
if (boldHeadline) {
|
|
455
|
+
return boldHeadline
|
|
451
456
|
}
|
|
452
457
|
const firstLine = content.split('\n').map((line) => line.trim()).find(Boolean)
|
|
453
458
|
return firstLine || null
|
|
454
459
|
}
|
|
455
460
|
|
|
461
|
+
const stripBoldHeadlines = (content: string): string => {
|
|
462
|
+
return content
|
|
463
|
+
.split('\n')
|
|
464
|
+
.filter((line) => {
|
|
465
|
+
const trimmed = line.trim()
|
|
466
|
+
return !/^\*\*.+?\*\*$/u.test(trimmed)
|
|
467
|
+
})
|
|
468
|
+
.join('\n')
|
|
469
|
+
.trim()
|
|
470
|
+
}
|
|
471
|
+
|
|
456
472
|
function groupMessagesIntoTurns(messages: Message[]): Turn[] {
|
|
457
473
|
const turns: Turn[] = []
|
|
458
474
|
let currentTurn: Turn | null = null
|
|
@@ -1054,8 +1070,8 @@ function ActionRow({ action }: { action: AssistantAction }) {
|
|
|
1054
1070
|
}
|
|
1055
1071
|
|
|
1056
1072
|
if (action.type === 'reasoning') {
|
|
1057
|
-
const
|
|
1058
|
-
const trimmed =
|
|
1073
|
+
const rawContent = action.messages.map(m => m.content).join('\n\n')
|
|
1074
|
+
const trimmed = rawContent.trim().toLowerCase()
|
|
1059
1075
|
const isPlaceholder = !trimmed || trimmed === 'reasoning' || trimmed === 'reasoning summary' || trimmed === 'thinking'
|
|
1060
1076
|
|
|
1061
1077
|
if (isPlaceholder) {
|
|
@@ -1065,6 +1081,12 @@ function ActionRow({ action }: { action: AssistantAction }) {
|
|
|
1065
1081
|
</div>
|
|
1066
1082
|
)
|
|
1067
1083
|
}
|
|
1084
|
+
|
|
1085
|
+
const content = stripBoldHeadlines(rawContent)
|
|
1086
|
+
|
|
1087
|
+
if (!content.trim()) {
|
|
1088
|
+
return null
|
|
1089
|
+
}
|
|
1068
1090
|
|
|
1069
1091
|
return (
|
|
1070
1092
|
<div>
|
|
@@ -23,37 +23,40 @@ export const SessionAuthBanner = ({
|
|
|
23
23
|
|
|
24
24
|
return (
|
|
25
25
|
<div className="px-4 py-3 border-b border-border bg-bg-secondary/70">
|
|
26
|
-
<div className="
|
|
27
|
-
<div className="
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
<div className="relative overflow-hidden rounded-2xl border border-border bg-bg-tertiary px-4 py-4 shadow-lg">
|
|
27
|
+
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_120%_at_0%_0%,rgba(16,185,129,0.18),transparent_55%)]" />
|
|
28
|
+
<div className="relative flex flex-col gap-4">
|
|
29
|
+
<div className="flex items-start gap-3">
|
|
30
|
+
<div className="w-9 h-9 rounded-lg bg-bg-primary border border-border flex items-center justify-center">
|
|
31
|
+
<Icons.Warning className="w-4 h-4 text-yellow-500" />
|
|
32
|
+
</div>
|
|
33
|
+
<div>
|
|
34
|
+
<h3 className="text-sm font-semibold text-text-primary">Connect this account</h3>
|
|
35
|
+
<p className="text-xs text-text-muted mt-1">
|
|
36
|
+
{pending
|
|
37
|
+
? 'Waiting for sign-in to complete. Return here after authorizing the account.'
|
|
38
|
+
: 'Choose ChatGPT for the fastest setup, or drop in an API key for direct access.'}
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
30
41
|
</div>
|
|
31
|
-
<div>
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
{pending
|
|
35
|
-
? 'Waiting for sign-in to complete. Return here after authorizing the account.'
|
|
36
|
-
: 'This account is not signed in. Connect with ChatGPT or provide an API key to enable new sessions and messaging.'}
|
|
37
|
-
</p>
|
|
38
|
-
</div>
|
|
39
|
-
</div>
|
|
40
|
-
<div className="flex flex-wrap gap-2">
|
|
41
|
-
<Button variant="primary" size="sm" onClick={onChatgpt}>
|
|
42
|
-
Sign in with ChatGPT
|
|
43
|
-
</Button>
|
|
44
|
-
<Button variant="ghost" size="sm" onClick={onApiKey}>
|
|
45
|
-
Use API key
|
|
46
|
-
</Button>
|
|
47
|
-
{pending && onCancel && (
|
|
48
|
-
<Button variant="ghost" size="sm" onClick={onCancel}>
|
|
49
|
-
Cancel login
|
|
42
|
+
<div className="flex flex-wrap gap-2">
|
|
43
|
+
<Button variant="primary" size="sm" onClick={onChatgpt}>
|
|
44
|
+
Sign in with ChatGPT
|
|
50
45
|
</Button>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<Button variant="ghost" size="sm" onClick={onRefresh}>
|
|
54
|
-
Check status
|
|
46
|
+
<Button variant="ghost" size="sm" onClick={onApiKey}>
|
|
47
|
+
Use API key
|
|
55
48
|
</Button>
|
|
56
|
-
|
|
49
|
+
{pending && onCancel && (
|
|
50
|
+
<Button variant="ghost" size="sm" onClick={onCancel}>
|
|
51
|
+
Cancel login
|
|
52
|
+
</Button>
|
|
53
|
+
)}
|
|
54
|
+
{onRefresh && (
|
|
55
|
+
<Button variant="ghost" size="sm" onClick={onRefresh}>
|
|
56
|
+
Check status
|
|
57
|
+
</Button>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
57
60
|
</div>
|
|
58
61
|
</div>
|
|
59
62
|
</div>
|
|
@@ -28,6 +28,10 @@ interface SessionComposerProps {
|
|
|
28
28
|
onEffortChange: (value: string) => void
|
|
29
29
|
showModelSelect: boolean
|
|
30
30
|
showEffortSelect: boolean
|
|
31
|
+
sandboxOptions: SelectOption[]
|
|
32
|
+
effectiveSandbox: string
|
|
33
|
+
onSandboxChange: (value: string) => void
|
|
34
|
+
showSandboxSelect: boolean
|
|
31
35
|
queuedCount: number
|
|
32
36
|
webSearchEnabled: boolean
|
|
33
37
|
onWebSearchToggle: () => void
|
|
@@ -65,6 +69,10 @@ export const SessionComposer = ({
|
|
|
65
69
|
onEffortChange,
|
|
66
70
|
showModelSelect,
|
|
67
71
|
showEffortSelect,
|
|
72
|
+
sandboxOptions,
|
|
73
|
+
effectiveSandbox,
|
|
74
|
+
onSandboxChange,
|
|
75
|
+
showSandboxSelect,
|
|
68
76
|
queuedCount,
|
|
69
77
|
webSearchEnabled,
|
|
70
78
|
onWebSearchToggle,
|
|
@@ -292,6 +300,18 @@ export const SessionComposer = ({
|
|
|
292
300
|
label="Reasoning Effort"
|
|
293
301
|
/>
|
|
294
302
|
)}
|
|
303
|
+
{showSandboxSelect && (
|
|
304
|
+
<Select
|
|
305
|
+
options={sandboxOptions}
|
|
306
|
+
value={effectiveSandbox}
|
|
307
|
+
onChange={onSandboxChange}
|
|
308
|
+
placeholder="Sandbox"
|
|
309
|
+
size="sm"
|
|
310
|
+
disabled={!canInteract}
|
|
311
|
+
className="min-w-[150px] sm:min-w-[220px] shrink-0"
|
|
312
|
+
label="Sandbox"
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
295
315
|
</div>
|
|
296
316
|
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
|
|
297
317
|
{queuedCount > 0 && (
|
|
@@ -196,4 +196,10 @@ export const Icons = {
|
|
|
196
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
197
|
</svg>
|
|
198
198
|
),
|
|
199
|
+
|
|
200
|
+
Key: ({ className }: IconProps) => (
|
|
201
|
+
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
202
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a5 5 0 11-4.757 6.683L7 17H4v3h3v-2h2v-2l1.243-1.317A5 5 0 0115 7z" />
|
|
203
|
+
</svg>
|
|
204
|
+
),
|
|
199
205
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect } from 'react'
|
|
2
2
|
import { hubClient, type HubProfile } from '../services/hub-client'
|
|
3
3
|
import { useAppStore } from '../store'
|
|
4
|
-
import type { Account, Thread } from '../types'
|
|
4
|
+
import type { Account, Thread, SandboxMode, SandboxPolicy } from '../types'
|
|
5
5
|
import { buildSystemMessage } from '../utils/item-format'
|
|
6
6
|
import { accountStatusFromRead, parseUsage, refreshAccountSnapshot, fetchAllModels, type AccountReadResult, type RateLimitResult } from '../utils/account-refresh'
|
|
7
7
|
|
|
@@ -19,6 +19,21 @@ type ItemPayload = { id?: string; type?: string } & Record<string, unknown>
|
|
|
19
19
|
const isThreadItem = (item: ItemPayload | undefined): item is { id: string; type: string } & Record<string, unknown> =>
|
|
20
20
|
!!item && typeof item.id === 'string' && typeof item.type === 'string'
|
|
21
21
|
|
|
22
|
+
const resolveSandboxPolicy = (mode?: SandboxMode | null): SandboxPolicy | null => {
|
|
23
|
+
switch (mode) {
|
|
24
|
+
case 'danger-full-access':
|
|
25
|
+
return { type: 'dangerFullAccess' }
|
|
26
|
+
case 'read-only':
|
|
27
|
+
return { type: 'readOnly' }
|
|
28
|
+
case 'workspace-write':
|
|
29
|
+
return { type: 'workspaceWrite' }
|
|
30
|
+
default:
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ALWAYS_ALLOW_SANDBOX_POLICY: SandboxPolicy = { type: 'workspaceWrite' }
|
|
36
|
+
|
|
22
37
|
const formatDate = (timestamp?: number): string => {
|
|
23
38
|
if (!timestamp) {
|
|
24
39
|
return ''
|
|
@@ -108,6 +123,9 @@ export const useHubConnection = () => {
|
|
|
108
123
|
method: string,
|
|
109
124
|
params?: unknown
|
|
110
125
|
): Promise<T | null> => {
|
|
126
|
+
if (disposed || !hubClient.isConnected()) {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
111
129
|
try {
|
|
112
130
|
return (await hubClient.request(profileId, method, params)) as T
|
|
113
131
|
} catch (error) {
|
|
@@ -145,6 +163,9 @@ export const useHubConnection = () => {
|
|
|
145
163
|
})
|
|
146
164
|
updateThread(threadId, { status: 'active' })
|
|
147
165
|
try {
|
|
166
|
+
const sandboxPolicy = resolveSandboxPolicy(next.sandbox)
|
|
167
|
+
const effectiveSandboxPolicy =
|
|
168
|
+
sandboxPolicy ?? (next.approvalPolicy === 'never' ? ALWAYS_ALLOW_SANDBOX_POLICY : null)
|
|
148
169
|
await hubClient.request(profileId, 'turn/start', {
|
|
149
170
|
threadId,
|
|
150
171
|
input: [{ type: 'text', text: next.text }],
|
|
@@ -153,6 +174,7 @@ export const useHubConnection = () => {
|
|
|
153
174
|
summary: next.summary ?? undefined,
|
|
154
175
|
cwd: next.cwd ?? undefined,
|
|
155
176
|
approvalPolicy: next.approvalPolicy ?? undefined,
|
|
177
|
+
sandboxPolicy: effectiveSandboxPolicy ?? undefined,
|
|
156
178
|
})
|
|
157
179
|
} catch (error) {
|
|
158
180
|
console.error(error)
|
|
@@ -189,6 +189,7 @@ export const useThreadHistory = () => {
|
|
|
189
189
|
setThreadApproval,
|
|
190
190
|
setThreadCwd,
|
|
191
191
|
setThreadTurnId,
|
|
192
|
+
setThreadTurnStartedAt,
|
|
192
193
|
} = useAppStore()
|
|
193
194
|
|
|
194
195
|
const inFlight = useRef<Set<string>>(new Set())
|
|
@@ -238,12 +239,35 @@ export const useThreadHistory = () => {
|
|
|
238
239
|
|
|
239
240
|
let activeTurn: TurnData | null = null
|
|
240
241
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
241
|
-
|
|
242
|
+
const turnStatus = turns[index]?.status
|
|
243
|
+
if (isTurnInProgress(turnStatus)) {
|
|
242
244
|
activeTurn = turns[index]
|
|
243
245
|
break
|
|
244
246
|
}
|
|
245
247
|
}
|
|
248
|
+
|
|
246
249
|
setThreadTurnId(selectedThreadId, activeTurn?.id ?? null)
|
|
250
|
+
|
|
251
|
+
// If there's an active turn, ensure startedAt is set (may have been set by hub connection)
|
|
252
|
+
if (activeTurn) {
|
|
253
|
+
const existingStartedAt = useAppStore.getState().threadTurnStartedAt[selectedThreadId]
|
|
254
|
+
if (!existingStartedAt) {
|
|
255
|
+
// Use current time as fallback if no startedAt was set
|
|
256
|
+
setThreadTurnStartedAt(selectedThreadId, Date.now())
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
// No active turn according to codex - check if backend thought it was active
|
|
260
|
+
const existingStartedAt = useAppStore.getState().threadTurnStartedAt[selectedThreadId]
|
|
261
|
+
if (existingStartedAt) {
|
|
262
|
+
// Backend had stale data - notify it to clear the active thread
|
|
263
|
+
console.log('[useThreadHistory] Clearing stale active thread from backend:', { threadId: selectedThreadId, profileId: thread.accountId })
|
|
264
|
+
hubClient.clearActiveThread({ profileId: thread.accountId, threadId: selectedThreadId }).catch((err) => {
|
|
265
|
+
console.error('[useThreadHistory] Failed to clear stale active thread:', err)
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
setThreadTurnStartedAt(selectedThreadId, null)
|
|
269
|
+
}
|
|
270
|
+
|
|
247
271
|
const nextStatus = thread.status === 'archived'
|
|
248
272
|
? 'archived'
|
|
249
273
|
: activeTurn
|
|
@@ -294,6 +318,7 @@ export const useThreadHistory = () => {
|
|
|
294
318
|
threads,
|
|
295
319
|
updateThread,
|
|
296
320
|
setThreadTurnId,
|
|
321
|
+
setThreadTurnStartedAt,
|
|
297
322
|
setThreadCwd,
|
|
298
323
|
])
|
|
299
324
|
}
|
|
@@ -339,6 +339,15 @@ class HubClient {
|
|
|
339
339
|
return data.threads ?? []
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
async clearActiveThread(params: { profileId: string; threadId: string }): Promise<void> {
|
|
343
|
+
const url = new URL(`/threads/active/${params.threadId}`, HUB_URL)
|
|
344
|
+
url.searchParams.set('profileId', params.profileId)
|
|
345
|
+
const response = await fetch(url.toString(), { method: 'DELETE' })
|
|
346
|
+
if (!response.ok) {
|
|
347
|
+
throw new Error('Failed to clear active thread')
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
342
351
|
async listReviews(params?: { profileId?: string; limit?: number; offset?: number }): Promise<ReviewSessionResult[]> {
|
|
343
352
|
const url = new URL('/reviews', HUB_URL)
|
|
344
353
|
if (params?.profileId) url.searchParams.set('profileId', params.profileId)
|
|
@@ -352,8 +361,12 @@ class HubClient {
|
|
|
352
361
|
return data.sessions ?? []
|
|
353
362
|
}
|
|
354
363
|
|
|
364
|
+
isConnected(): boolean {
|
|
365
|
+
return this.ws !== null && this.ws.readyState === WebSocket.OPEN
|
|
366
|
+
}
|
|
367
|
+
|
|
355
368
|
private async sendRequest(profileId: string, method: string, params?: unknown): Promise<unknown> {
|
|
356
|
-
if (!this.ws) {
|
|
369
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
357
370
|
console.error('[HubClient] WebSocket not connected')
|
|
358
371
|
throw new Error('WebSocket not connected')
|
|
359
372
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { create } from 'zustand'
|
|
2
|
-
import type { Account, Thread, Message, ApprovalRequest, TabType, ModelInfo, ReasoningEffort, ReasoningSummary, ApprovalPolicy, QueuedMessage, ReviewSession } from '../types'
|
|
2
|
+
import type { Account, Thread, Message, ApprovalRequest, TabType, ModelInfo, ReasoningEffort, ReasoningSummary, ApprovalPolicy, QueuedMessage, ReviewSession, SandboxMode } from '../types'
|
|
3
3
|
|
|
4
4
|
interface AppState {
|
|
5
5
|
accounts: Account[]
|
|
@@ -16,6 +16,7 @@ interface AppState {
|
|
|
16
16
|
threadCwds: Record<string, string>
|
|
17
17
|
threadApprovals: Record<string, ApprovalPolicy>
|
|
18
18
|
threadWebSearch: Record<string, boolean>
|
|
19
|
+
threadSandboxes: Record<string, SandboxMode>
|
|
19
20
|
threadTurnIds: Record<string, string>
|
|
20
21
|
threadTurnStartedAt: Record<string, number>
|
|
21
22
|
threadLastTurnDuration: Record<string, number>
|
|
@@ -56,6 +57,7 @@ interface AppState {
|
|
|
56
57
|
setThreadSummary: (threadId: string, summary: ReasoningSummary) => void
|
|
57
58
|
setThreadCwd: (threadId: string, cwd: string) => void
|
|
58
59
|
setThreadApproval: (threadId: string, approval: ApprovalPolicy) => void
|
|
60
|
+
setThreadSandbox: (threadId: string, sandbox: SandboxMode | null) => void
|
|
59
61
|
setThreadWebSearch: (threadId: string, enabled: boolean) => void
|
|
60
62
|
setThreadTurnId: (threadId: string, turnId: string | null) => void
|
|
61
63
|
setThreadTurnStartedAt: (threadId: string, startedAt: number | null) => void
|
|
@@ -106,12 +108,20 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
106
108
|
threadCwds: {},
|
|
107
109
|
threadApprovals: {},
|
|
108
110
|
threadWebSearch: {},
|
|
111
|
+
threadSandboxes: {},
|
|
109
112
|
threadTurnIds: {},
|
|
110
113
|
threadTurnStartedAt: {},
|
|
111
114
|
threadLastTurnDuration: {},
|
|
112
115
|
threadTokenUsage: {},
|
|
113
116
|
threadPendingAccountSwitch: {},
|
|
114
|
-
backendToUiThreadId: {
|
|
117
|
+
backendToUiThreadId: (() => {
|
|
118
|
+
try {
|
|
119
|
+
const stored = localStorage.getItem('backendToUiThreadId')
|
|
120
|
+
return stored ? JSON.parse(stored) : {}
|
|
121
|
+
} catch {
|
|
122
|
+
return {}
|
|
123
|
+
}
|
|
124
|
+
})(),
|
|
115
125
|
messages: {},
|
|
116
126
|
queuedMessages: {},
|
|
117
127
|
approvals: [],
|
|
@@ -150,6 +160,9 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
150
160
|
const threadApprovals = Object.fromEntries(
|
|
151
161
|
Object.entries(state.threadApprovals).filter(([threadId]) => remainingThreadIds.has(threadId))
|
|
152
162
|
)
|
|
163
|
+
const threadSandboxes = Object.fromEntries(
|
|
164
|
+
Object.entries(state.threadSandboxes).filter(([threadId]) => remainingThreadIds.has(threadId))
|
|
165
|
+
)
|
|
153
166
|
const threadTokenUsage = Object.fromEntries(
|
|
154
167
|
Object.entries(state.threadTokenUsage).filter(([threadId]) => remainingThreadIds.has(threadId))
|
|
155
168
|
)
|
|
@@ -173,6 +186,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
173
186
|
threadSummaries,
|
|
174
187
|
threadCwds,
|
|
175
188
|
threadApprovals,
|
|
189
|
+
threadSandboxes,
|
|
176
190
|
threadTokenUsage,
|
|
177
191
|
modelsByAccount,
|
|
178
192
|
accountLoginIds,
|
|
@@ -221,6 +235,9 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
221
235
|
threadCwds: Object.fromEntries(
|
|
222
236
|
Object.entries(state.threadCwds).filter(([threadId]) => threadId !== id)
|
|
223
237
|
),
|
|
238
|
+
threadSandboxes: Object.fromEntries(
|
|
239
|
+
Object.entries(state.threadSandboxes).filter(([threadId]) => threadId !== id)
|
|
240
|
+
),
|
|
224
241
|
threadTokenUsage: Object.fromEntries(
|
|
225
242
|
Object.entries(state.threadTokenUsage).filter(([threadId]) => threadId !== id)
|
|
226
243
|
),
|
|
@@ -284,6 +301,19 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
284
301
|
[threadId]: approval,
|
|
285
302
|
},
|
|
286
303
|
})),
|
|
304
|
+
setThreadSandbox: (threadId, sandbox) => set((state) => {
|
|
305
|
+
if (!sandbox) {
|
|
306
|
+
const next = { ...state.threadSandboxes }
|
|
307
|
+
delete next[threadId]
|
|
308
|
+
return { threadSandboxes: next }
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
threadSandboxes: {
|
|
312
|
+
...state.threadSandboxes,
|
|
313
|
+
[threadId]: sandbox,
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
}),
|
|
287
317
|
setThreadWebSearch: (threadId, enabled) => set((state) => ({
|
|
288
318
|
threadWebSearch: {
|
|
289
319
|
...state.threadWebSearch,
|
|
@@ -345,16 +375,21 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
345
375
|
}
|
|
346
376
|
}),
|
|
347
377
|
setBackendToUiThreadId: (backendThreadId, uiThreadId) => set((state) => {
|
|
378
|
+
let newMapping: Record<string, string>
|
|
348
379
|
if (uiThreadId === null) {
|
|
349
380
|
const { [backendThreadId]: _, ...rest } = state.backendToUiThreadId
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
backendToUiThreadId: {
|
|
381
|
+
newMapping = rest
|
|
382
|
+
} else {
|
|
383
|
+
newMapping = {
|
|
354
384
|
...state.backendToUiThreadId,
|
|
355
385
|
[backendThreadId]: uiThreadId,
|
|
356
|
-
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
localStorage.setItem('backendToUiThreadId', JSON.stringify(newMapping))
|
|
390
|
+
} catch {
|
|
357
391
|
}
|
|
392
|
+
return { backendToUiThreadId: newMapping }
|
|
358
393
|
}),
|
|
359
394
|
resolveThreadId: (threadId: string): string => {
|
|
360
395
|
const state = get()
|
|
@@ -6,6 +6,11 @@ export type TabType = 'sessions' | 'reviews' | 'archive'
|
|
|
6
6
|
export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
|
7
7
|
export type ReasoningSummary = 'auto' | 'concise' | 'detailed' | 'none'
|
|
8
8
|
export type ApprovalPolicy = 'untrusted' | 'on-failure' | 'on-request' | 'never'
|
|
9
|
+
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
|
|
10
|
+
export type SandboxPolicy =
|
|
11
|
+
| { type: 'dangerFullAccess' }
|
|
12
|
+
| { type: 'readOnly' }
|
|
13
|
+
| { type: 'workspaceWrite'; networkAccess?: boolean }
|
|
9
14
|
export type ReviewStatus = 'pending' | 'running' | 'completed' | 'failed'
|
|
10
15
|
|
|
11
16
|
export type CommandAction =
|
|
@@ -104,6 +109,7 @@ export interface QueuedMessage {
|
|
|
104
109
|
summary?: ReasoningSummary | null
|
|
105
110
|
cwd?: string | null
|
|
106
111
|
approvalPolicy?: ApprovalPolicy | null
|
|
112
|
+
sandbox?: SandboxMode | null
|
|
107
113
|
createdAt: number
|
|
108
114
|
}
|
|
109
115
|
|
|
@@ -96,9 +96,15 @@ export const accountStatusFromRead = (result: AccountReadResult): AccountStatus
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
export const fetchAllModels = async (profileId: string): Promise<ModelInfo[]> => {
|
|
99
|
+
if (!hubClient.isConnected()) {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
99
102
|
const models: ModelInfo[] = []
|
|
100
103
|
let cursor: string | null = null
|
|
101
104
|
for (let page = 0; page < 10; page += 1) {
|
|
105
|
+
if (!hubClient.isConnected()) {
|
|
106
|
+
return models
|
|
107
|
+
}
|
|
102
108
|
let result: ModelListResult | null = null
|
|
103
109
|
try {
|
|
104
110
|
result = (await hubClient.request(profileId, 'model/list', {
|
|
@@ -125,6 +131,9 @@ export const refreshAccountSnapshot = async (
|
|
|
125
131
|
updateAccount: (id: string, updater: (account: Account) => Account) => void,
|
|
126
132
|
setModelsForAccount: (id: string, models: ModelInfo[]) => void
|
|
127
133
|
): Promise<void> => {
|
|
134
|
+
if (!hubClient.isConnected()) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
128
137
|
let accountResult: AccountReadResult | null = null
|
|
129
138
|
try {
|
|
130
139
|
accountResult = (await hubClient.request(profileId, 'account/read', {
|
package/bin/better-codex.cjs
CHANGED
|
@@ -153,6 +153,7 @@ const isRoot = (dir) =>
|
|
|
153
153
|
existsSync(join(dir, 'apps', 'web', 'package.json'));
|
|
154
154
|
|
|
155
155
|
const findRoot = (explicit) => {
|
|
156
|
+
// If --root is provided, honor it as-is (after validation).
|
|
156
157
|
if (explicit) {
|
|
157
158
|
const resolved = resolve(explicit);
|
|
158
159
|
if (isRoot(resolved)) {
|
|
@@ -161,6 +162,8 @@ const findRoot = (explicit) => {
|
|
|
161
162
|
throw new Error(`Specified root does not contain apps/: ${explicit}`);
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
// Prefer the directory the command is executed from (or an ancestor),
|
|
166
|
+
// never the installed package directory.
|
|
164
167
|
let current = resolve(process.cwd());
|
|
165
168
|
for (let depth = 0; depth < 8; depth += 1) {
|
|
166
169
|
if (isRoot(current)) {
|
|
@@ -171,21 +174,9 @@ const findRoot = (explicit) => {
|
|
|
171
174
|
current = parent;
|
|
172
175
|
}
|
|
173
176
|
|
|
174
|
-
const bundledRoot = resolve(dirname(__filename), '..');
|
|
175
|
-
if (isRoot(bundledRoot)) {
|
|
176
|
-
return bundledRoot;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const npmGlobalRoot = resolve(dirname(__filename), '..', '..');
|
|
180
|
-
const npmPackageRoot = join(npmGlobalRoot, 'better-codex');
|
|
181
|
-
if (isRoot(npmPackageRoot)) {
|
|
182
|
-
return npmPackageRoot;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
177
|
throw new Error(
|
|
186
|
-
`Could not locate Better Codex apps.\n` +
|
|
187
|
-
`
|
|
188
|
-
` ${c.cyan}npm install -g better-codex${c.reset}`
|
|
178
|
+
`Could not locate Better Codex apps starting from ${process.cwd()}.\n` +
|
|
179
|
+
`Run the CLI from your project root or pass ${c.cyan}--root <path>${c.reset}.`
|
|
189
180
|
);
|
|
190
181
|
};
|
|
191
182
|
|