jupyterlab-codex-sidebar 0.1.4 → 0.1.6
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/.claude/settings.local.json +9 -0
- package/.github/workflows/unit-tests.yml +27 -0
- package/.jupyterlab-playwright.log +0 -0
- package/README.md +83 -9
- package/docs/images/codex-sidebar-screenshot.png +0 -0
- package/jupyterlab_codex/handlers.py +938 -297
- package/jupyterlab_codex/labextension/package.json +13 -3
- package/jupyterlab_codex/labextension/static/525.224526d045c727069de6.js +2 -0
- package/jupyterlab_codex/labextension/static/737.e7de3ad9dd6ded798340.js +1 -0
- package/jupyterlab_codex/labextension/static/remoteEntry.6ef5e7167763a316c000.js +1 -0
- package/jupyterlab_codex/protocol.py +297 -0
- package/jupyterlab_codex/runner.py +58 -15
- package/jupyterlab_codex/sessions.py +582 -97
- package/lib/codexChat.d.ts +13 -0
- package/lib/codexChat.js +2506 -0
- package/lib/codexChat.js.map +1 -0
- package/lib/codexChatAttachmentDedup.d.ts +10 -0
- package/lib/codexChatAttachmentDedup.js +35 -0
- package/lib/codexChatAttachmentDedup.js.map +1 -0
- package/lib/codexChatAttachmentLimit.d.ts +18 -0
- package/lib/codexChatAttachmentLimit.js +50 -0
- package/lib/codexChatAttachmentLimit.js.map +1 -0
- package/lib/codexChatAttachmentState.d.ts +15 -0
- package/lib/codexChatAttachmentState.js +16 -0
- package/lib/codexChatAttachmentState.js.map +1 -0
- package/lib/codexChatDocumentUtils.d.ts +70 -0
- package/lib/codexChatDocumentUtils.js +506 -0
- package/lib/codexChatDocumentUtils.js.map +1 -0
- package/lib/codexChatFormatting.d.ts +11 -0
- package/lib/codexChatFormatting.js +83 -0
- package/lib/codexChatFormatting.js.map +1 -0
- package/lib/codexChatNotice.d.ts +3 -0
- package/lib/codexChatNotice.js +74 -0
- package/lib/codexChatNotice.js.map +1 -0
- package/lib/codexChatPersistence.d.ts +35 -0
- package/lib/codexChatPersistence.js +158 -0
- package/lib/codexChatPersistence.js.map +1 -0
- package/lib/codexChatPrimitives.d.ts +44 -0
- package/lib/codexChatPrimitives.js +156 -0
- package/lib/codexChatPrimitives.js.map +1 -0
- package/lib/codexChatRender.d.ts +24 -0
- package/lib/codexChatRender.js +293 -0
- package/lib/codexChatRender.js.map +1 -0
- package/lib/codexChatSessionFactory.d.ts +15 -0
- package/lib/codexChatSessionFactory.js +45 -0
- package/lib/codexChatSessionFactory.js.map +1 -0
- package/lib/codexChatSessionKey.d.ts +3 -0
- package/lib/codexChatSessionKey.js +14 -0
- package/lib/codexChatSessionKey.js.map +1 -0
- package/lib/codexChatStorage.d.ts +4 -0
- package/lib/codexChatStorage.js +37 -0
- package/lib/codexChatStorage.js.map +1 -0
- package/lib/codexSessionResolver.d.ts +12 -0
- package/lib/codexSessionResolver.js +38 -0
- package/lib/codexSessionResolver.js.map +1 -0
- package/lib/handlers/activitySummarizer.d.ts +15 -0
- package/lib/handlers/activitySummarizer.js +327 -0
- package/lib/handlers/activitySummarizer.js.map +1 -0
- package/lib/handlers/codexMessageTypes.d.ts +30 -0
- package/lib/handlers/codexMessageTypes.js +2 -0
- package/lib/handlers/codexMessageTypes.js.map +1 -0
- package/lib/handlers/codexMessageUtils.d.ts +46 -0
- package/lib/handlers/codexMessageUtils.js +144 -0
- package/lib/handlers/codexMessageUtils.js.map +1 -0
- package/lib/handlers/handleCodexSocketMessage.d.ts +107 -0
- package/lib/handlers/handleCodexSocketMessage.js +78 -0
- package/lib/handlers/handleCodexSocketMessage.js.map +1 -0
- package/lib/handlers/sessionSyncHandler.d.ts +34 -0
- package/lib/handlers/sessionSyncHandler.js +181 -0
- package/lib/handlers/sessionSyncHandler.js.map +1 -0
- package/lib/hooks/useCodexSocket.d.ts +15 -0
- package/lib/hooks/useCodexSocket.js +84 -0
- package/lib/hooks/useCodexSocket.js.map +1 -0
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/panel.d.ts +1 -11
- package/lib/panel.js +1 -2815
- package/lib/panel.js.map +1 -1
- package/lib/protocol.d.ts +235 -0
- package/lib/protocol.js +278 -0
- package/lib/protocol.js.map +1 -0
- package/package.json +13 -3
- package/playwright.config.cjs +27 -0
- package/playwright.unit.config.cjs +19 -0
- package/pyproject.toml +1 -1
- package/release.sh +52 -14
- package/scripts/run_playwright_e2e.sh +96 -0
- package/scripts/run_playwright_freeze_repro.sh +58 -0
- package/scripts/run_playwright_queue_repro.sh +60 -0
- package/scripts/run_playwright_repro.sh +55 -0
- package/src/codexChat.tsx +3914 -0
- package/src/codexChatAttachmentDedup.ts +47 -0
- package/src/codexChatAttachmentLimit.ts +81 -0
- package/src/codexChatAttachmentState.ts +37 -0
- package/src/codexChatDocumentUtils.ts +644 -0
- package/src/codexChatFormatting.ts +94 -0
- package/src/codexChatNotice.ts +95 -0
- package/src/codexChatPersistence.ts +191 -0
- package/src/codexChatPrimitives.tsx +446 -0
- package/src/codexChatRender.tsx +376 -0
- package/src/codexChatSessionFactory.ts +79 -0
- package/src/codexChatSessionKey.ts +16 -0
- package/src/codexChatStorage.ts +36 -0
- package/src/codexSessionResolver.ts +56 -0
- package/src/handlers/activitySummarizer.ts +369 -0
- package/src/handlers/codexMessageTypes.ts +34 -0
- package/src/handlers/codexMessageUtils.ts +217 -0
- package/src/handlers/handleCodexSocketMessage.ts +204 -0
- package/src/handlers/sessionSyncHandler.ts +308 -0
- package/src/hooks/useCodexSocket.ts +109 -0
- package/src/index.ts +1 -1
- package/src/panel.tsx +1 -4184
- package/src/protocol.ts +582 -0
- package/style/index.css +480 -11
- package/test-results/.last-run.json +4 -0
- package/test.py +0 -0
- package/tests/e2e/cell-output-error-tail.spec.js +156 -0
- package/tests/e2e/codex-ui-test-helpers.js +138 -0
- package/tests/e2e/fixtures/notebooks/error-output-tail.ipynb +58 -0
- package/tests/e2e/fixtures/notebooks/error-output-tail.py +19 -0
- package/tests/e2e/fixtures/notebooks/tab1.ipynb +322 -0
- package/tests/e2e/fixtures/notebooks/tab1.py +272 -0
- package/tests/e2e/fixtures/notebooks/tab2.ipynb +252 -0
- package/tests/e2e/fixtures/notebooks/tab2.py +231 -0
- package/tests/e2e/fixtures/notebooks/tab3.ipynb +403 -0
- package/tests/e2e/fixtures/notebooks/tab3.py +331 -0
- package/tests/e2e/fixtures/notebooks/tab4.py +339 -0
- package/tests/e2e/freeze-notebook-tabs-repro.spec.js +295 -0
- package/tests/e2e/mock-codex-cli-flood.py +127 -0
- package/tests/e2e/mock-codex-cli-prompt-echo.py +88 -0
- package/tests/e2e/mock-codex-cli.py +95 -0
- package/tests/e2e/queue-multitab-repro.spec.js +189 -0
- package/tests/test_handlers.py +116 -0
- package/tests/test_protocol.py +169 -0
- package/tests/test_session_store_limits.py +50 -0
- package/tests/unit/codexChatAttachmentDedup.spec.ts +56 -0
- package/tests/unit/codexChatAttachmentLimit.spec.ts +57 -0
- package/tests/unit/codexChatAttachmentState.spec.ts +71 -0
- package/tests/unit/codexChatDocumentUtils.spec.ts +63 -0
- package/tests/unit/codexChatLimit.spec.ts +18 -0
- package/tests/unit/codexChatNotice.spec.ts +45 -0
- package/tests/unit/codexChatPersistence.spec.ts +199 -0
- package/tests/unit/codexChatSessionFactory.spec.ts +94 -0
- package/tests/unit/codexChatSessionKey.spec.ts +18 -0
- package/tests/unit/codexMessageUtils.spec.ts +89 -0
- package/tests/unit/codexSessionResolver.spec.ts +92 -0
- package/tests/unit/handleCodexSocketMessage.spec.ts +476 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/webpack.config.js +6 -0
- package/jupyterlab_codex/labextension/static/504.335f3447c84ba3d74517.js +0 -2
- package/jupyterlab_codex/labextension/static/972.8e856719e40acc1ef4cb.js +0 -1
- package/jupyterlab_codex/labextension/static/remoteEntry.a2982f776a1f0f515640.js +0 -1
- /package/jupyterlab_codex/labextension/static/{504.335f3447c84ba3d74517.js.LICENSE.txt → 525.224526d045c727069de6.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,3914 @@
|
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { ReactWidget, Dialog, showDialog } from '@jupyterlab/apputils';
|
|
3
|
+
import type { JupyterFrontEnd } from '@jupyterlab/application';
|
|
4
|
+
import { INotebookTracker } from '@jupyterlab/notebook';
|
|
5
|
+
import { Message } from '@lumino/messaging';
|
|
6
|
+
import { useCodexSocket } from './hooks/useCodexSocket';
|
|
7
|
+
import { handleCodexSocketMessage } from './handlers/handleCodexSocketMessage';
|
|
8
|
+
import { blobToDataUrl, MessageText, SelectionPreviewCode, StatusPill } from './codexChatRender';
|
|
9
|
+
import {
|
|
10
|
+
type ModelCatalogEntry,
|
|
11
|
+
parseModelCatalog,
|
|
12
|
+
buildCancelMessage,
|
|
13
|
+
buildDeleteAllSessionsMessage,
|
|
14
|
+
buildSendMessage,
|
|
15
|
+
buildStartSessionMessage
|
|
16
|
+
} from './protocol';
|
|
17
|
+
import {
|
|
18
|
+
coerceMessageContextPreview as coerceMessageContextPreviewShared,
|
|
19
|
+
coerceSessionHistory as coerceSessionHistoryShared,
|
|
20
|
+
coerceSelectionPreview as coerceSelectionPreviewShared,
|
|
21
|
+
type MessageContextPreview,
|
|
22
|
+
type SelectionPreview,
|
|
23
|
+
truncateEnd as truncateEndShared
|
|
24
|
+
} from './handlers/codexMessageUtils';
|
|
25
|
+
import { type CodexRateLimitsSnapshot } from './handlers/codexMessageTypes';
|
|
26
|
+
import {
|
|
27
|
+
clampNumber,
|
|
28
|
+
coerceRateLimitsSnapshot,
|
|
29
|
+
formatDurationShort,
|
|
30
|
+
formatResetsIn,
|
|
31
|
+
formatRunDuration,
|
|
32
|
+
formatTokenCount,
|
|
33
|
+
getBrowserNotificationPermission,
|
|
34
|
+
percentLeftFromUsed,
|
|
35
|
+
safeParseDateMs,
|
|
36
|
+
truncateMiddle
|
|
37
|
+
} from './codexChatFormatting';
|
|
38
|
+
import { isSessionStartNotice, normalizeSessionStartedNotice } from './codexChatNotice';
|
|
39
|
+
import {
|
|
40
|
+
type SessionThreadSyncEvent,
|
|
41
|
+
STORAGE_KEY_SESSION_THREADS,
|
|
42
|
+
STORAGE_KEY_SESSION_THREADS_EVENT,
|
|
43
|
+
buildSessionThreadSyncEvent,
|
|
44
|
+
coerceSessionThreadSyncEvent,
|
|
45
|
+
getStoredSessionThreadCount,
|
|
46
|
+
writeSessionThreadSyncEvent,
|
|
47
|
+
markDeleteAllPending,
|
|
48
|
+
clearDeleteAllPending,
|
|
49
|
+
hasDeleteAllPending,
|
|
50
|
+
parseSessionKey,
|
|
51
|
+
persistStoredSessionThreads,
|
|
52
|
+
readStoredThreadId
|
|
53
|
+
} from './codexChatPersistence';
|
|
54
|
+
import {
|
|
55
|
+
createSession as createBaseSession,
|
|
56
|
+
createThreadResetSession as createBaseThreadResetSession,
|
|
57
|
+
type SessionCreateOptions
|
|
58
|
+
} from './codexChatSessionFactory';
|
|
59
|
+
import { resolveCurrentSessionKey, resolveSessionKey } from './codexChatSessionKey';
|
|
60
|
+
import { resolveMessageSessionKey as resolveMessageSessionKeyForMessage } from './codexSessionResolver';
|
|
61
|
+
import {
|
|
62
|
+
type DocumentWidgetLike,
|
|
63
|
+
type NotebookMode,
|
|
64
|
+
MESSAGE_SELECTION_PREVIEW_DISPLAY_MAX_CHARS,
|
|
65
|
+
MESSAGE_SELECTION_PREVIEW_STORED_MAX_CHARS,
|
|
66
|
+
captureDocumentViewState,
|
|
67
|
+
findDocumentWidgetByPath,
|
|
68
|
+
getActiveCellOutput,
|
|
69
|
+
getActiveCellText,
|
|
70
|
+
getActiveDocumentWidget,
|
|
71
|
+
getDocumentContext,
|
|
72
|
+
getSelectedContext,
|
|
73
|
+
getSelectedTextFromActiveCell,
|
|
74
|
+
getSelectedTextFromFileEditor,
|
|
75
|
+
getSupportedDocumentPath,
|
|
76
|
+
isNotebookWidget,
|
|
77
|
+
normalizeSelectionPreviewText,
|
|
78
|
+
restoreDocumentViewState,
|
|
79
|
+
toCellOutputPreview,
|
|
80
|
+
toMessageSelectionPreview,
|
|
81
|
+
} from './codexChatDocumentUtils';
|
|
82
|
+
import { resolveCellAttachmentState } from './codexChatAttachmentState';
|
|
83
|
+
import {
|
|
84
|
+
buildActiveCellOutputSignature,
|
|
85
|
+
buildActiveCellSelectionSignature,
|
|
86
|
+
isDuplicateActiveCellAttachmentSignature,
|
|
87
|
+
makeActiveCellAttachmentDedupKey
|
|
88
|
+
} from './codexChatAttachmentDedup';
|
|
89
|
+
import {
|
|
90
|
+
buildAttachmentTruncationNotice,
|
|
91
|
+
limitActiveCellAttachmentPayload,
|
|
92
|
+
resolveSentAttachmentTruncation
|
|
93
|
+
} from './codexChatAttachmentLimit';
|
|
94
|
+
import {
|
|
95
|
+
ArrowDownIcon,
|
|
96
|
+
ArrowUpIcon,
|
|
97
|
+
BatteryIcon,
|
|
98
|
+
CellAttachmentIcon,
|
|
99
|
+
CheckIcon,
|
|
100
|
+
ContextWindowIcon,
|
|
101
|
+
FileIcon,
|
|
102
|
+
GaugeIcon,
|
|
103
|
+
GearIcon,
|
|
104
|
+
ImageIcon,
|
|
105
|
+
PlusIcon,
|
|
106
|
+
PortalMenu,
|
|
107
|
+
ReasoningEffortIcon,
|
|
108
|
+
ShieldIcon,
|
|
109
|
+
StopIcon,
|
|
110
|
+
XIcon
|
|
111
|
+
} from './codexChatPrimitives';
|
|
112
|
+
import {
|
|
113
|
+
hasStoredValue,
|
|
114
|
+
safeLocalStorageGet,
|
|
115
|
+
safeLocalStorageRemove,
|
|
116
|
+
safeLocalStorageSet
|
|
117
|
+
} from './codexChatStorage';
|
|
118
|
+
|
|
119
|
+
const truncateEnd = truncateEndShared;
|
|
120
|
+
|
|
121
|
+
export class CodexPanel extends ReactWidget {
|
|
122
|
+
private _app: JupyterFrontEnd;
|
|
123
|
+
private _notebooks: INotebookTracker;
|
|
124
|
+
|
|
125
|
+
constructor(app: JupyterFrontEnd, notebooks: INotebookTracker) {
|
|
126
|
+
super();
|
|
127
|
+
this._app = app;
|
|
128
|
+
this._notebooks = notebooks;
|
|
129
|
+
this.addClass('jp-CodexPanel');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
render(): JSX.Element {
|
|
133
|
+
return <CodexChat app={this._app} notebooks={this._notebooks} />;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
protected onAfterAttach(msg: Message): void {
|
|
137
|
+
super.onAfterAttach(msg);
|
|
138
|
+
this.focusComposer();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
protected onActivateRequest(msg: Message): void {
|
|
142
|
+
super.onActivateRequest(msg);
|
|
143
|
+
this.focusComposer();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private focusComposer(): void {
|
|
147
|
+
window.setTimeout(() => {
|
|
148
|
+
const textarea = this.node.querySelector<HTMLTextAreaElement>('.jp-CodexComposer textarea');
|
|
149
|
+
if (!textarea || textarea.disabled) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
textarea.focus({ preventScroll: true });
|
|
153
|
+
const cursor = textarea.value.length;
|
|
154
|
+
textarea.setSelectionRange(cursor, cursor);
|
|
155
|
+
}, 0);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type TextRole = 'user' | 'assistant' | 'system';
|
|
160
|
+
type ChatAttachments = {
|
|
161
|
+
images?: number;
|
|
162
|
+
};
|
|
163
|
+
type StoredSelectionPreviewEntry = {
|
|
164
|
+
contentHash: string;
|
|
165
|
+
preview: MessageContextPreview | null;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
function markLocalizedStartNotice(session: NotebookSession): NotebookSession {
|
|
169
|
+
const firstEntry = session.messages[0];
|
|
170
|
+
if (
|
|
171
|
+
firstEntry &&
|
|
172
|
+
firstEntry.kind === 'text' &&
|
|
173
|
+
firstEntry.role === 'system' &&
|
|
174
|
+
isSessionStartNotice(firstEntry.text)
|
|
175
|
+
) {
|
|
176
|
+
firstEntry.sessionResolution = 'client';
|
|
177
|
+
}
|
|
178
|
+
return session;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type ChatEntry =
|
|
182
|
+
| {
|
|
183
|
+
kind: 'text';
|
|
184
|
+
id: string;
|
|
185
|
+
role: TextRole;
|
|
186
|
+
text: string;
|
|
187
|
+
sessionResolution?: unknown;
|
|
188
|
+
attachments?: ChatAttachments;
|
|
189
|
+
selectionPreview?: SelectionPreview;
|
|
190
|
+
cellOutputPreview?: SelectionPreview;
|
|
191
|
+
}
|
|
192
|
+
| {
|
|
193
|
+
kind: 'run-divider';
|
|
194
|
+
id: string;
|
|
195
|
+
elapsedMs: number;
|
|
196
|
+
}
|
|
197
|
+
| {
|
|
198
|
+
kind: 'activity';
|
|
199
|
+
id: string;
|
|
200
|
+
item: ActivityItem;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
type PendingImageAttachment = {
|
|
204
|
+
id: string;
|
|
205
|
+
file: File;
|
|
206
|
+
previewUrl: string;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
type RunState = 'ready' | 'running';
|
|
210
|
+
type PanelStatus = 'disconnected' | RunState;
|
|
211
|
+
type ProgressKind = '' | 'reasoning';
|
|
212
|
+
type ActivityPhase = 'started' | 'completed' | '';
|
|
213
|
+
type ActivityCategory = 'reasoning' | 'command' | 'file' | 'tool' | 'event';
|
|
214
|
+
type ActivityItem = {
|
|
215
|
+
id: string;
|
|
216
|
+
ts: number;
|
|
217
|
+
category: ActivityCategory;
|
|
218
|
+
phase: ActivityPhase;
|
|
219
|
+
title: string;
|
|
220
|
+
detail: string;
|
|
221
|
+
raw: string;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
type CodexChatProps = {
|
|
225
|
+
app: JupyterFrontEnd;
|
|
226
|
+
notebooks: INotebookTracker;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
type CliDefaultsSnapshot = {
|
|
230
|
+
model: string | null;
|
|
231
|
+
reasoningEffort: string | null;
|
|
232
|
+
availableModels?: ModelCatalogEntry[];
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
type ConversationMode = 'resume' | 'fallback';
|
|
236
|
+
type NotebookSession = {
|
|
237
|
+
threadId: string;
|
|
238
|
+
messages: ChatEntry[];
|
|
239
|
+
runState: RunState;
|
|
240
|
+
activeRunId: string | null;
|
|
241
|
+
runStartedAt: number | null;
|
|
242
|
+
progress: string;
|
|
243
|
+
progressKind: ProgressKind;
|
|
244
|
+
pairedOk: boolean | null;
|
|
245
|
+
pairedPath: string;
|
|
246
|
+
pairedOsPath: string;
|
|
247
|
+
pairedMessage: string;
|
|
248
|
+
notebookMode: NotebookMode | null;
|
|
249
|
+
selectedModelOption: ModelOptionValue;
|
|
250
|
+
selectedReasoningEffort: ReasoningOptionValue;
|
|
251
|
+
selectedSandboxMode: SandboxMode;
|
|
252
|
+
effectiveSandboxMode: SandboxMode | null;
|
|
253
|
+
conversationMode: ConversationMode;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
type ActiveCellAttachmentSignatures = {
|
|
257
|
+
selectionSignature?: string;
|
|
258
|
+
cellOutputSignature?: string;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
type ModelOptionValue = '__config__' | string;
|
|
262
|
+
type ModelOption = {
|
|
263
|
+
label: string;
|
|
264
|
+
value: string;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const FALLBACK_MODEL_OPTIONS: ModelOption[] = [];
|
|
268
|
+
type ReasoningOption = {
|
|
269
|
+
label: string;
|
|
270
|
+
value: string;
|
|
271
|
+
};
|
|
272
|
+
type ReasoningOptionValue = '__config__' | string;
|
|
273
|
+
const DEFAULT_REASONING_EFFORT = 'medium';
|
|
274
|
+
const MAX_REASONING_EFFORT_BARS = 4;
|
|
275
|
+
const SANDBOX_OPTIONS = [
|
|
276
|
+
{ label: 'Default permission', value: 'workspace-write' },
|
|
277
|
+
{ label: 'Full access', value: 'danger-full-access' },
|
|
278
|
+
{ label: 'Read only', value: 'read-only' }
|
|
279
|
+
] as const;
|
|
280
|
+
type SandboxMode = (typeof SANDBOX_OPTIONS)[number]['value'];
|
|
281
|
+
|
|
282
|
+
function readDefaultModelOption(): ModelOptionValue {
|
|
283
|
+
const savedModel = readStoredModel();
|
|
284
|
+
if (savedModel && savedModel !== '__config__' && savedModel !== '__custom__') {
|
|
285
|
+
return savedModel;
|
|
286
|
+
}
|
|
287
|
+
return '__config__';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function readDefaultReasoningEffortOption(): ReasoningOptionValue {
|
|
291
|
+
return readStoredReasoningEffort();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function readDefaultSandboxModeOption(): SandboxMode {
|
|
295
|
+
return readStoredSandboxMode();
|
|
296
|
+
}
|
|
297
|
+
const AUTO_SAVE_STORAGE_KEY = 'jupyterlab-codex:auto-save-before-send';
|
|
298
|
+
const MODEL_STORAGE_KEY = 'jupyterlab-codex:model';
|
|
299
|
+
const COMMAND_PATH_STORAGE_KEY = 'jupyterlab-codex:command-path';
|
|
300
|
+
const REASONING_STORAGE_KEY = 'jupyterlab-codex:reasoning-effort';
|
|
301
|
+
const SANDBOX_MODE_STORAGE_KEY = 'jupyterlab-codex:sandbox-mode';
|
|
302
|
+
const SETTINGS_OPEN_STORAGE_KEY = 'jupyterlab-codex:settings-open';
|
|
303
|
+
const NOTIFY_ON_DONE_STORAGE_KEY = 'jupyterlab-codex:notify-on-done';
|
|
304
|
+
const NOTIFY_ON_DONE_MIN_SECONDS_STORAGE_KEY = 'jupyterlab-codex:notify-on-done-min-seconds';
|
|
305
|
+
const INCLUDE_ACTIVE_CELL_STORAGE_KEY = 'jupyterlab-codex:include-active-cell';
|
|
306
|
+
const INCLUDE_ACTIVE_CELL_OUTPUT_STORAGE_KEY = 'jupyterlab-codex:include-active-cell-output';
|
|
307
|
+
const SELECTION_PREVIEWS_STORAGE_KEY = 'jupyterlab-codex:selection-previews';
|
|
308
|
+
|
|
309
|
+
const MAX_IMAGE_ATTACHMENTS = 4;
|
|
310
|
+
const MAX_IMAGE_ATTACHMENT_BYTES = 4 * 1024 * 1024; // Avoid huge WebSocket payloads.
|
|
311
|
+
const MAX_IMAGE_ATTACHMENT_TOTAL_BYTES = 6 * 1024 * 1024;
|
|
312
|
+
const MAX_ACTIVE_CELL_SELECTION_CHARS = 4000;
|
|
313
|
+
const MAX_ACTIVE_CELL_OUTPUT_CHARS = 20000;
|
|
314
|
+
const MAX_STORED_SELECTION_PREVIEW_THREADS = 80;
|
|
315
|
+
const MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD = 10;
|
|
316
|
+
const MAX_SESSION_MESSAGES = 100;
|
|
317
|
+
const SOCKET_MESSAGE_BATCH_SIZE = 8;
|
|
318
|
+
const SOCKET_MESSAGE_MAX_QUEUE = 1200;
|
|
319
|
+
const SOCKET_MESSAGE_FALLBACK_FLUSH_MS = 16;
|
|
320
|
+
const READ_ONLY_PERMISSION_WARNING = 'Code changes are not available with the current permission (Read only).';
|
|
321
|
+
|
|
322
|
+
function findModelLabel(model: string, options: readonly ModelOption[]): string {
|
|
323
|
+
const match = options.find(option => option.value === model);
|
|
324
|
+
return match ? match.label : truncateMiddle(model, 32);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function coerceReasoningEffort(value: unknown): string {
|
|
328
|
+
return typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function coerceReasoningText(value: unknown): string {
|
|
332
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function coerceReasoningEffortEntry(value: unknown): string {
|
|
336
|
+
if (typeof value === 'string') {
|
|
337
|
+
return coerceReasoningText(value);
|
|
338
|
+
}
|
|
339
|
+
if (value && typeof value === 'object' && 'reasoningEffort' in (value as Record<string, unknown>)) {
|
|
340
|
+
return coerceReasoningText((value as { reasoningEffort?: unknown }).reasoningEffort);
|
|
341
|
+
}
|
|
342
|
+
return '';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function readModelOptions(rawModels: unknown): ModelOption[] {
|
|
346
|
+
const catalog = parseModelCatalog(rawModels);
|
|
347
|
+
return catalog.map(entry => ({ label: entry.displayName, value: entry.model }));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function getReasoningEffortBars(
|
|
351
|
+
effort: string,
|
|
352
|
+
effortOptions: readonly ReasoningOption[]
|
|
353
|
+
): number {
|
|
354
|
+
if (!effort || effort === '__config__') {
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
if (effortOptions.length <= 0) {
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
const index = effortOptions.findIndex(option => option.value === effort);
|
|
361
|
+
if (index < 0) {
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
if (effortOptions.length === 1) {
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
367
|
+
const scale = MAX_REASONING_EFFORT_BARS - 1;
|
|
368
|
+
return Math.max(
|
|
369
|
+
1,
|
|
370
|
+
Math.min(
|
|
371
|
+
MAX_REASONING_EFFORT_BARS,
|
|
372
|
+
Math.floor((index * scale) / (effortOptions.length - 1)) + 1
|
|
373
|
+
)
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function buildReasoningOptions(rawModels: unknown, selectedModel: string): ReasoningOption[] {
|
|
378
|
+
const catalog = parseModelCatalog(rawModels);
|
|
379
|
+
const normalizedModel = selectedModel.trim();
|
|
380
|
+
const modelByName = catalog.find(item => item.model === normalizedModel);
|
|
381
|
+
const reasons =
|
|
382
|
+
modelByName?.reasoningEfforts && modelByName.reasoningEfforts.length > 0
|
|
383
|
+
? modelByName.reasoningEfforts
|
|
384
|
+
: modelByName?.defaultReasoningEffort
|
|
385
|
+
? [modelByName.defaultReasoningEffort]
|
|
386
|
+
: catalog.flatMap(item => item.reasoningEfforts ?? []);
|
|
387
|
+
|
|
388
|
+
const deduped = new Map<string, ReasoningOption>();
|
|
389
|
+
for (const reason of reasons) {
|
|
390
|
+
const label = coerceReasoningEffortEntry(reason);
|
|
391
|
+
const normalized = coerceReasoningEffort(label);
|
|
392
|
+
if (!normalized || deduped.has(normalized)) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
deduped.set(normalized, { value: normalized, label: label || normalized });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return Array.from(deduped.values());
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function resolveFallbackReasoningEffort(options: readonly ReasoningOption[]): string {
|
|
402
|
+
const medium = options.find(option => coerceReasoningEffort(option.value) === DEFAULT_REASONING_EFFORT);
|
|
403
|
+
if (medium) {
|
|
404
|
+
return medium.value;
|
|
405
|
+
}
|
|
406
|
+
return options[0]?.value ?? DEFAULT_REASONING_EFFORT;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function findReasoningLabel(value: string, options: readonly ReasoningOption[]): string {
|
|
410
|
+
const match = options.find(option => option.value === value);
|
|
411
|
+
if (match) {
|
|
412
|
+
return match.label;
|
|
413
|
+
}
|
|
414
|
+
const raw = coerceReasoningText(value);
|
|
415
|
+
if (!raw) {
|
|
416
|
+
return 'Reasoning';
|
|
417
|
+
}
|
|
418
|
+
return raw;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function isKnownSandboxMode(value: string): value is SandboxMode {
|
|
422
|
+
return SANDBOX_OPTIONS.some(option => option.value === value);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function coerceSandboxMode(value: unknown): SandboxMode | null {
|
|
426
|
+
if (typeof value !== 'string') {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
const normalized = value.trim().toLowerCase().replace(/_/g, '-');
|
|
430
|
+
return isKnownSandboxMode(normalized) ? normalized : null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function coerceNotebookMode(value: unknown): NotebookMode | null {
|
|
434
|
+
if (value === 'ipynb' || value === 'jupytext_py' || value === 'plain_py' || value === 'unsupported') {
|
|
435
|
+
return value;
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function coerceConversationMode(value: unknown): ConversationMode | null {
|
|
441
|
+
if (value === 'resume' || value === 'fallback') {
|
|
442
|
+
return value;
|
|
443
|
+
}
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function inferNotebookModeFromPath(path: string): NotebookMode {
|
|
448
|
+
const normalized = (path || '').trim().toLowerCase();
|
|
449
|
+
if (normalized.endsWith('.ipynb')) {
|
|
450
|
+
return 'ipynb';
|
|
451
|
+
}
|
|
452
|
+
if (normalized.endsWith('.py')) {
|
|
453
|
+
return 'plain_py';
|
|
454
|
+
}
|
|
455
|
+
return 'unsupported';
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function createSessionEventId(): string {
|
|
459
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
460
|
+
return crypto.randomUUID();
|
|
461
|
+
}
|
|
462
|
+
return `${Date.now()}-${Math.floor(Math.random() * 0x100000000).toString(16)}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function hashSelectionPreviewContent(content: string): string {
|
|
466
|
+
// Stable lightweight hash for local UI-only metadata matching.
|
|
467
|
+
const normalized = (content || '').replace(/\r\n/g, '\n');
|
|
468
|
+
let hash = 2166136261;
|
|
469
|
+
for (let idx = 0; idx < normalized.length; idx += 1) {
|
|
470
|
+
hash ^= normalized.charCodeAt(idx);
|
|
471
|
+
hash +=
|
|
472
|
+
(hash << 1) +
|
|
473
|
+
(hash << 4) +
|
|
474
|
+
(hash << 7) +
|
|
475
|
+
(hash << 8) +
|
|
476
|
+
(hash << 24);
|
|
477
|
+
}
|
|
478
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function normalizeStoredSelectionPreviewEntry(
|
|
482
|
+
value: unknown
|
|
483
|
+
): StoredSelectionPreviewEntry | null {
|
|
484
|
+
if (!value || typeof value !== 'object') {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
const raw = value as Record<string, unknown>;
|
|
488
|
+
const contentHash = typeof raw.contentHash === 'string' ? raw.contentHash.trim() : '';
|
|
489
|
+
if (!contentHash) {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const previewRaw = raw.preview;
|
|
494
|
+
if (!previewRaw) {
|
|
495
|
+
return { contentHash, preview: null };
|
|
496
|
+
}
|
|
497
|
+
const preview = coerceMessageContextPreview(previewRaw);
|
|
498
|
+
if (!preview) {
|
|
499
|
+
return { contentHash, preview: null };
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
contentHash,
|
|
503
|
+
preview
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function readStoredSelectionPreviewsByThread(): Map<string, StoredSelectionPreviewEntry[]> {
|
|
508
|
+
const raw = safeLocalStorageGet(SELECTION_PREVIEWS_STORAGE_KEY);
|
|
509
|
+
if (!raw) {
|
|
510
|
+
return new Map();
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
const parsed = JSON.parse(raw);
|
|
514
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
515
|
+
return new Map();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const next = new Map<string, StoredSelectionPreviewEntry[]>();
|
|
519
|
+
for (const [threadIdRaw, entriesRaw] of Object.entries(parsed as Record<string, unknown>)) {
|
|
520
|
+
const threadId = typeof threadIdRaw === 'string' ? threadIdRaw.trim() : '';
|
|
521
|
+
if (!threadId || !Array.isArray(entriesRaw)) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const entries: StoredSelectionPreviewEntry[] = [];
|
|
525
|
+
for (const entryCandidate of entriesRaw) {
|
|
526
|
+
const entry = normalizeStoredSelectionPreviewEntry(entryCandidate);
|
|
527
|
+
if (!entry) {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
entries.push(entry);
|
|
531
|
+
}
|
|
532
|
+
if (entries.length <= 0) {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
next.set(threadId, entries.slice(-MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD));
|
|
536
|
+
}
|
|
537
|
+
return next;
|
|
538
|
+
} catch {
|
|
539
|
+
return new Map();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function persistStoredSelectionPreviewsByThread(
|
|
544
|
+
previewsByThread: Map<string, StoredSelectionPreviewEntry[]>
|
|
545
|
+
): void {
|
|
546
|
+
const serialized: Record<string, Array<{ contentHash: string; preview?: MessageContextPreview }>> = {};
|
|
547
|
+
const entries = Array.from(previewsByThread.entries()).slice(-MAX_STORED_SELECTION_PREVIEW_THREADS);
|
|
548
|
+
for (const [threadId, threadEntries] of entries) {
|
|
549
|
+
if (!threadId || !Array.isArray(threadEntries) || threadEntries.length <= 0) {
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const normalizedEntries = threadEntries
|
|
553
|
+
.filter(item => item && typeof item.contentHash === 'string' && item.contentHash)
|
|
554
|
+
.slice(-MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD)
|
|
555
|
+
.map(item =>
|
|
556
|
+
item.preview
|
|
557
|
+
? { contentHash: item.contentHash, preview: item.preview }
|
|
558
|
+
: { contentHash: item.contentHash }
|
|
559
|
+
);
|
|
560
|
+
if (normalizedEntries.length > 0) {
|
|
561
|
+
serialized[threadId] = normalizedEntries;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
safeLocalStorageSet(SELECTION_PREVIEWS_STORAGE_KEY, JSON.stringify(serialized));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function coerceSelectionPreview(value: unknown): SelectionPreview | undefined {
|
|
568
|
+
return coerceSelectionPreviewShared(value, {
|
|
569
|
+
normalize: normalizeSelectionPreviewText,
|
|
570
|
+
maxChars: MESSAGE_SELECTION_PREVIEW_STORED_MAX_CHARS
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function coerceMessageContextPreview(value: unknown): MessageContextPreview | undefined {
|
|
575
|
+
return coerceMessageContextPreviewShared(value, {
|
|
576
|
+
normalize: normalizeSelectionPreviewText,
|
|
577
|
+
maxChars: MESSAGE_SELECTION_PREVIEW_STORED_MAX_CHARS
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function coerceSessionHistory(
|
|
582
|
+
raw: any
|
|
583
|
+
): Array<{
|
|
584
|
+
role: TextRole;
|
|
585
|
+
content: string;
|
|
586
|
+
selectionPreview?: SelectionPreview;
|
|
587
|
+
cellOutputPreview?: SelectionPreview;
|
|
588
|
+
}> {
|
|
589
|
+
return coerceSessionHistoryShared(raw, {
|
|
590
|
+
normalize: normalizeSelectionPreviewText,
|
|
591
|
+
maxChars: MESSAGE_SELECTION_PREVIEW_STORED_MAX_CHARS
|
|
592
|
+
}) as Array<{
|
|
593
|
+
role: TextRole;
|
|
594
|
+
content: string;
|
|
595
|
+
selectionPreview?: SelectionPreview;
|
|
596
|
+
cellOutputPreview?: SelectionPreview;
|
|
597
|
+
}>;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function createSession(
|
|
601
|
+
path: string,
|
|
602
|
+
intro: string,
|
|
603
|
+
options?: {
|
|
604
|
+
threadId?: string;
|
|
605
|
+
reuseStoredThread?: boolean;
|
|
606
|
+
sessionKey?: string;
|
|
607
|
+
}
|
|
608
|
+
): NotebookSession {
|
|
609
|
+
const session = createBaseSession<NotebookSession, ModelOptionValue, ReasoningOptionValue, SandboxMode>(
|
|
610
|
+
path,
|
|
611
|
+
intro,
|
|
612
|
+
options as SessionCreateOptions | undefined,
|
|
613
|
+
{
|
|
614
|
+
readStoredThreadId,
|
|
615
|
+
readDefaultModelOption,
|
|
616
|
+
readDefaultReasoningEffortOption,
|
|
617
|
+
readDefaultSandboxModeOption,
|
|
618
|
+
normalizeSystemText
|
|
619
|
+
}
|
|
620
|
+
);
|
|
621
|
+
return markLocalizedStartNotice(session);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function createThreadResetSession(path: string, sessionKey: string, threadId: string): NotebookSession {
|
|
625
|
+
const session = createBaseThreadResetSession<NotebookSession, ModelOptionValue, ReasoningOptionValue, SandboxMode>(
|
|
626
|
+
path,
|
|
627
|
+
sessionKey,
|
|
628
|
+
threadId,
|
|
629
|
+
{
|
|
630
|
+
readStoredThreadId,
|
|
631
|
+
readDefaultModelOption,
|
|
632
|
+
readDefaultReasoningEffortOption,
|
|
633
|
+
readDefaultSandboxModeOption,
|
|
634
|
+
normalizeSystemText
|
|
635
|
+
}
|
|
636
|
+
);
|
|
637
|
+
return markLocalizedStartNotice(session);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function normalizeSystemText(role: TextRole, text: string): string {
|
|
641
|
+
if (role !== 'system') {
|
|
642
|
+
return text;
|
|
643
|
+
}
|
|
644
|
+
return normalizeSessionStartedNotice(text) ?? text;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function readStoredModel(): string {
|
|
648
|
+
return safeLocalStorageGet(MODEL_STORAGE_KEY) ?? '';
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function readStoredCommandPath(): string {
|
|
652
|
+
return safeLocalStorageGet(COMMAND_PATH_STORAGE_KEY) ?? '';
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function readStoredAutoSave(): boolean {
|
|
656
|
+
return (safeLocalStorageGet(AUTO_SAVE_STORAGE_KEY) ?? 'true') !== 'false';
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function readStoredIncludeActiveCell(): boolean {
|
|
660
|
+
return (safeLocalStorageGet(INCLUDE_ACTIVE_CELL_STORAGE_KEY) ?? 'true') !== 'false';
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function readStoredIncludeActiveCellOutput(): boolean {
|
|
664
|
+
return (safeLocalStorageGet(INCLUDE_ACTIVE_CELL_OUTPUT_STORAGE_KEY) ?? 'true') !== 'false';
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function readStoredReasoningEffort(): ReasoningOptionValue {
|
|
668
|
+
try {
|
|
669
|
+
const stored = safeLocalStorageGet(REASONING_STORAGE_KEY) ?? '';
|
|
670
|
+
if (stored === '__config__') {
|
|
671
|
+
return '__config__';
|
|
672
|
+
}
|
|
673
|
+
const normalized = stored.trim();
|
|
674
|
+
return normalized ? (normalized as ReasoningOptionValue) : '__config__';
|
|
675
|
+
} catch {
|
|
676
|
+
return '__config__';
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function readStoredSandboxMode(): SandboxMode {
|
|
681
|
+
const stored = safeLocalStorageGet(SANDBOX_MODE_STORAGE_KEY) ?? '';
|
|
682
|
+
return isKnownSandboxMode(stored) ? stored : SANDBOX_OPTIONS[0].value;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function persistModel(model: string): void {
|
|
686
|
+
safeLocalStorageSet(MODEL_STORAGE_KEY, model);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function persistCommandPath(commandPath: string): void {
|
|
690
|
+
safeLocalStorageSet(COMMAND_PATH_STORAGE_KEY, commandPath);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function persistAutoSave(enabled: boolean): void {
|
|
694
|
+
safeLocalStorageSet(AUTO_SAVE_STORAGE_KEY, enabled ? 'true' : 'false');
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function persistIncludeActiveCell(enabled: boolean): void {
|
|
698
|
+
safeLocalStorageSet(INCLUDE_ACTIVE_CELL_STORAGE_KEY, enabled ? 'true' : 'false');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function persistIncludeActiveCellOutput(enabled: boolean): void {
|
|
702
|
+
safeLocalStorageSet(INCLUDE_ACTIVE_CELL_OUTPUT_STORAGE_KEY, enabled ? 'true' : 'false');
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function persistReasoningEffort(value: ReasoningOptionValue): void {
|
|
706
|
+
safeLocalStorageSet(REASONING_STORAGE_KEY, value);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function persistSandboxMode(value: SandboxMode): void {
|
|
710
|
+
safeLocalStorageSet(SANDBOX_MODE_STORAGE_KEY, value);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function readStoredSettingsOpen(): boolean {
|
|
714
|
+
return (safeLocalStorageGet(SETTINGS_OPEN_STORAGE_KEY) ?? 'false') === 'true';
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function persistSettingsOpen(enabled: boolean): void {
|
|
718
|
+
safeLocalStorageSet(SETTINGS_OPEN_STORAGE_KEY, enabled ? 'true' : 'false');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function readStoredNotifyOnDone(): boolean {
|
|
722
|
+
return (safeLocalStorageGet(NOTIFY_ON_DONE_STORAGE_KEY) ?? 'true') === 'true';
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function persistNotifyOnDone(enabled: boolean): void {
|
|
726
|
+
safeLocalStorageSet(NOTIFY_ON_DONE_STORAGE_KEY, enabled ? 'true' : 'false');
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function readStoredNotifyOnDoneMinSeconds(): number {
|
|
730
|
+
const raw = safeLocalStorageGet(NOTIFY_ON_DONE_MIN_SECONDS_STORAGE_KEY);
|
|
731
|
+
if (raw == null) {
|
|
732
|
+
return 30;
|
|
733
|
+
}
|
|
734
|
+
const parsed = Number(raw);
|
|
735
|
+
return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : 0;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function persistNotifyOnDoneMinSeconds(seconds: number): void {
|
|
739
|
+
safeLocalStorageSet(NOTIFY_ON_DONE_MIN_SECONDS_STORAGE_KEY, String(Math.max(0, Math.trunc(seconds))));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function CodexChat(props: CodexChatProps): JSX.Element {
|
|
743
|
+
const [sessions, setSessions] = useState<Map<string, NotebookSession>>(new Map());
|
|
744
|
+
const sessionsRef = useRef<Map<string, NotebookSession>>(new Map());
|
|
745
|
+
const [currentNotebookPath, setCurrentNotebookPath] = useState<string>('');
|
|
746
|
+
const currentNotebookPathRef = useRef<string>('');
|
|
747
|
+
const [currentNotebookSessionKey, setCurrentNotebookSessionKey] = useState<string>('');
|
|
748
|
+
const currentNotebookSessionKeyRef = useRef<string>('');
|
|
749
|
+
const [cliDefaults, setCliDefaults] = useState<CliDefaultsSnapshot>({ model: null, reasoningEffort: null });
|
|
750
|
+
const [modelOptions, setModelOptions] = useState<ModelOption[]>(() => FALLBACK_MODEL_OPTIONS);
|
|
751
|
+
const [modelOption, setModelOption] = useState<ModelOptionValue>(() => readDefaultModelOption());
|
|
752
|
+
const [reasoningEffort, setReasoningEffort] = useState<ReasoningOptionValue>(() =>
|
|
753
|
+
readDefaultReasoningEffortOption()
|
|
754
|
+
);
|
|
755
|
+
const [sandboxMode, setSandboxMode] = useState<SandboxMode>(() => readDefaultSandboxModeOption());
|
|
756
|
+
const [commandPath, setCommandPath] = useState<string>(() => readStoredCommandPath());
|
|
757
|
+
const [autoSaveBeforeSend, setAutoSaveBeforeSend] = useState<boolean>(() => readStoredAutoSave());
|
|
758
|
+
const [includeActiveCell, setIncludeActiveCell] = useState<boolean>(() => readStoredIncludeActiveCell());
|
|
759
|
+
const [includeActiveCellOutput, setIncludeActiveCellOutput] = useState<boolean>(() =>
|
|
760
|
+
readStoredIncludeActiveCellOutput()
|
|
761
|
+
);
|
|
762
|
+
const [currentDocumentIsNotebookEditor, setCurrentDocumentIsNotebookEditor] = useState(false);
|
|
763
|
+
const [notifyOnDone, setNotifyOnDone] = useState<boolean>(() => readStoredNotifyOnDone());
|
|
764
|
+
const [notifyOnDoneMinSeconds, setNotifyOnDoneMinSeconds] = useState<number>(() => readStoredNotifyOnDoneMinSeconds());
|
|
765
|
+
const [settingsOpen, setSettingsOpen] = useState<boolean>(() => readStoredSettingsOpen());
|
|
766
|
+
const [input, setInput] = useState('');
|
|
767
|
+
const [pendingImages, setPendingImages] = useState<PendingImageAttachment[]>([]);
|
|
768
|
+
const pendingImagesRef = useRef<PendingImageAttachment[]>([]);
|
|
769
|
+
const inputRef = useRef<string>('');
|
|
770
|
+
const inputDraftsRef = useRef<Map<string, string>>(new Map());
|
|
771
|
+
const {
|
|
772
|
+
socketRef: wsRef,
|
|
773
|
+
socketConnected,
|
|
774
|
+
isReconnecting,
|
|
775
|
+
reconnect: reconnectSocket
|
|
776
|
+
} = useCodexSocket({
|
|
777
|
+
onOpen: onSocketOpen,
|
|
778
|
+
onClose: onSocketClose,
|
|
779
|
+
onMessage: onSocketMessage
|
|
780
|
+
});
|
|
781
|
+
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
782
|
+
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
|
783
|
+
const [reasoningMenuOpen, setReasoningMenuOpen] = useState(false);
|
|
784
|
+
const [usagePopoverOpen, setUsagePopoverOpen] = useState(false);
|
|
785
|
+
const [permissionMenuOpen, setPermissionMenuOpen] = useState(false);
|
|
786
|
+
const [contextPopoverOpen, setContextPopoverOpen] = useState(false);
|
|
787
|
+
const [isPlainPyRunInProgress, setIsPlainPyRunInProgress] = useState<boolean>(false);
|
|
788
|
+
const [cellAttachmentPopoverOpen, setCellAttachmentPopoverOpen] = useState(false);
|
|
789
|
+
const [selectionPopover, setSelectionPopover] = useState<{
|
|
790
|
+
messageId: string;
|
|
791
|
+
preview: MessageContextPreview;
|
|
792
|
+
} | null>(null);
|
|
793
|
+
const storedSelectionPreviewsRef = useRef<Map<string, StoredSelectionPreviewEntry[]>>(
|
|
794
|
+
readStoredSelectionPreviewsByThread()
|
|
795
|
+
);
|
|
796
|
+
const previousSessionThreadIdsRef = useRef<Map<string, string>>(new Map());
|
|
797
|
+
const [rateLimits, setRateLimits] = useState<CodexRateLimitsSnapshot | null>(null);
|
|
798
|
+
const runToSessionKeyRef = useRef<Map<string, string>>(new Map());
|
|
799
|
+
const activeSessionKeyByPathRef = useRef<Map<string, string>>(new Map());
|
|
800
|
+
const sessionThreadSyncIdRef = useRef<string>(createSessionEventId());
|
|
801
|
+
const lastRateLimitsRefreshRef = useRef<number>(0);
|
|
802
|
+
const pendingRefreshPathsRef = useRef<Set<string>>(new Set());
|
|
803
|
+
const activeDocumentWidgetRef = useRef<DocumentWidgetLike | null>(null);
|
|
804
|
+
const socketMessageQueueRef = useRef<unknown[]>([]);
|
|
805
|
+
const socketMessageFlushTimerRef = useRef<number | null>(null);
|
|
806
|
+
const socketMessageFlushRafRef = useRef<number | null>(null);
|
|
807
|
+
const lastActiveCellAttachmentSignatureRef = useRef<Map<string, ActiveCellAttachmentSignatures>>(new Map());
|
|
808
|
+
const notifyOnDoneRef = useRef<boolean>(notifyOnDone);
|
|
809
|
+
const notifyOnDoneMinSecondsRef = useRef<number>(notifyOnDoneMinSeconds);
|
|
810
|
+
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
811
|
+
const endRef = useRef<HTMLDivElement | null>(null);
|
|
812
|
+
const modelMenuWrapRef = useRef<HTMLDivElement | null>(null);
|
|
813
|
+
const reasoningMenuWrapRef = useRef<HTMLDivElement | null>(null);
|
|
814
|
+
const usageMenuWrapRef = useRef<HTMLDivElement | null>(null);
|
|
815
|
+
const permissionMenuWrapRef = useRef<HTMLDivElement | null>(null);
|
|
816
|
+
const contextMenuWrapRef = useRef<HTMLDivElement | null>(null);
|
|
817
|
+
const modelBtnRef = useRef<HTMLButtonElement>(null);
|
|
818
|
+
const modelPopoverRef = useRef<HTMLDivElement>(null);
|
|
819
|
+
const reasoningBtnRef = useRef<HTMLButtonElement>(null);
|
|
820
|
+
const reasoningPopoverRef = useRef<HTMLDivElement>(null);
|
|
821
|
+
const usageBtnRef = useRef<HTMLButtonElement>(null);
|
|
822
|
+
const usagePopoverRef = useRef<HTMLDivElement>(null);
|
|
823
|
+
const permissionBtnRef = useRef<HTMLButtonElement>(null);
|
|
824
|
+
const permissionPopoverRef = useRef<HTMLDivElement>(null);
|
|
825
|
+
const contextBtnRef = useRef<HTMLButtonElement>(null);
|
|
826
|
+
const contextPopoverRef = useRef<HTMLDivElement>(null);
|
|
827
|
+
const plainPyRunSessionKeyRef = useRef<string>('');
|
|
828
|
+
const cellAttachmentAnchorRef = useRef<HTMLDivElement | null>(null);
|
|
829
|
+
const cellAttachmentPopoverRef = useRef<HTMLDivElement>(null);
|
|
830
|
+
const cellAttachmentPopoverCloseTimerRef = useRef<number | null>(null);
|
|
831
|
+
const contextPopoverCloseTimerRef = useRef<number | null>(null);
|
|
832
|
+
const selectionPopoverAnchorRef = useRef<HTMLElement | null>(null);
|
|
833
|
+
const selectionPopoverRef = useRef<HTMLDivElement>(null);
|
|
834
|
+
const notebookLabelRef = useRef<HTMLSpanElement | null>(null);
|
|
835
|
+
const [isNotebookLabelTruncated, setIsNotebookLabelTruncated] = useState(false);
|
|
836
|
+
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
837
|
+
const storedThreadCount = useMemo<number>(() => getStoredSessionThreadCount(), [sessions]);
|
|
838
|
+
const selectedModel = modelOption === '__config__' ? '' : modelOption;
|
|
839
|
+
const autoModel = cliDefaults.model;
|
|
840
|
+
const autoReasoningEffort = cliDefaults.reasoningEffort;
|
|
841
|
+
const reasoningModel = modelOption === '__config__' ? (autoModel || '') : modelOption;
|
|
842
|
+
const reasoningOptions = useMemo<ReasoningOption[]>(
|
|
843
|
+
() => buildReasoningOptions(cliDefaults.availableModels, reasoningModel),
|
|
844
|
+
[cliDefaults.availableModels, reasoningModel]
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
useEffect(() => {
|
|
848
|
+
const dynamicModelOptions = readModelOptions(cliDefaults.availableModels);
|
|
849
|
+
setModelOptions(dynamicModelOptions);
|
|
850
|
+
}, [cliDefaults.availableModels]);
|
|
851
|
+
|
|
852
|
+
useEffect(() => {
|
|
853
|
+
if (reasoningEffort === '__config__') {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (reasoningOptions.length > 0 && !reasoningOptions.some(option => option.value === reasoningEffort)) {
|
|
857
|
+
setCurrentSessionReasoningEffort('__config__');
|
|
858
|
+
}
|
|
859
|
+
}, [reasoningEffort, reasoningOptions]);
|
|
860
|
+
|
|
861
|
+
useEffect(() => {
|
|
862
|
+
if (reasoningEffort !== '__config__') {
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (coerceReasoningEffort(autoReasoningEffort || '')) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const fallbackReasoning = resolveFallbackReasoningEffort(reasoningOptions);
|
|
869
|
+
setCurrentSessionReasoningEffort(fallbackReasoning);
|
|
870
|
+
}, [reasoningEffort, autoReasoningEffort, reasoningOptions]);
|
|
871
|
+
|
|
872
|
+
useEffect(() => {
|
|
873
|
+
if (modelOption === '__config__') {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (!modelOptions.some(option => option.value === modelOption)) {
|
|
877
|
+
setCurrentSessionModelOption('__config__');
|
|
878
|
+
}
|
|
879
|
+
}, [modelOption, modelOptions]);
|
|
880
|
+
|
|
881
|
+
useEffect(() => {
|
|
882
|
+
if (modelOption !== '__config__') {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if ((autoModel || '').trim()) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const firstModel = modelOptions[0]?.value;
|
|
889
|
+
if (!firstModel) {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
setCurrentSessionModelOption(firstModel);
|
|
893
|
+
}, [modelOption, autoModel, modelOptions]);
|
|
894
|
+
|
|
895
|
+
function setInputDraftForSession(sessionKey: string, value: string): void {
|
|
896
|
+
if (!sessionKey) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const nextDrafts = new Map(inputDraftsRef.current);
|
|
901
|
+
if (value) {
|
|
902
|
+
nextDrafts.set(sessionKey, value);
|
|
903
|
+
} else {
|
|
904
|
+
nextDrafts.delete(sessionKey);
|
|
905
|
+
}
|
|
906
|
+
inputDraftsRef.current = nextDrafts;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function saveCurrentInputDraft(value: string): void {
|
|
910
|
+
const sessionKey = currentNotebookSessionKeyRef.current || '';
|
|
911
|
+
if (!sessionKey) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
setInputDraftForSession(sessionKey, value);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function clearInputForCurrentSession(): void {
|
|
918
|
+
const sessionKey = currentNotebookSessionKeyRef.current || '';
|
|
919
|
+
if (!sessionKey) {
|
|
920
|
+
setInput('');
|
|
921
|
+
inputRef.current = '';
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
saveCurrentInputDraft('');
|
|
925
|
+
setInput('');
|
|
926
|
+
inputRef.current = '';
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function updateInput(nextValue: string): void {
|
|
930
|
+
saveCurrentInputDraft(nextValue);
|
|
931
|
+
setInput(nextValue);
|
|
932
|
+
inputRef.current = nextValue;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function restoreInput(sessionKey: string): void {
|
|
936
|
+
const restoredInput = sessionKey ? inputDraftsRef.current.get(sessionKey) || '' : '';
|
|
937
|
+
inputRef.current = restoredInput;
|
|
938
|
+
setInput(restoredInput);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
useEffect(() => {
|
|
942
|
+
inputRef.current = input;
|
|
943
|
+
}, [input]);
|
|
944
|
+
|
|
945
|
+
useEffect(() => {
|
|
946
|
+
sessionsRef.current = sessions;
|
|
947
|
+
currentNotebookSessionKeyRef.current = currentNotebookSessionKey;
|
|
948
|
+
}, [sessions]);
|
|
949
|
+
|
|
950
|
+
useEffect(() => {
|
|
951
|
+
currentNotebookSessionKeyRef.current = currentNotebookSessionKey;
|
|
952
|
+
persistStoredSessionThreads(sessions);
|
|
953
|
+
}, [sessions, currentNotebookSessionKey]);
|
|
954
|
+
|
|
955
|
+
useEffect(() => {
|
|
956
|
+
const sessionKey = currentNotebookSessionKey || '';
|
|
957
|
+
if (!sessionKey) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
const session = sessions.get(sessionKey);
|
|
961
|
+
if (!session) {
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
setModelOption(prev => (prev === session.selectedModelOption ? prev : session.selectedModelOption));
|
|
965
|
+
setReasoningEffort(prev =>
|
|
966
|
+
prev === session.selectedReasoningEffort ? prev : session.selectedReasoningEffort
|
|
967
|
+
);
|
|
968
|
+
setSandboxMode(prev => (prev === session.selectedSandboxMode ? prev : session.selectedSandboxMode));
|
|
969
|
+
}, [currentNotebookSessionKey, sessions]);
|
|
970
|
+
|
|
971
|
+
useEffect(() => {
|
|
972
|
+
pendingImagesRef.current = pendingImages;
|
|
973
|
+
}, [pendingImages]);
|
|
974
|
+
|
|
975
|
+
useEffect(() => {
|
|
976
|
+
return () => {
|
|
977
|
+
// Avoid leaking blob URLs if the panel unmounts.
|
|
978
|
+
for (const image of pendingImagesRef.current) {
|
|
979
|
+
URL.revokeObjectURL(image.previewUrl);
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
}, []);
|
|
983
|
+
|
|
984
|
+
useEffect(() => {
|
|
985
|
+
persistModel(selectedModel);
|
|
986
|
+
}, [selectedModel]);
|
|
987
|
+
|
|
988
|
+
useEffect(() => {
|
|
989
|
+
persistAutoSave(autoSaveBeforeSend);
|
|
990
|
+
}, [autoSaveBeforeSend]);
|
|
991
|
+
|
|
992
|
+
useEffect(() => {
|
|
993
|
+
persistIncludeActiveCell(includeActiveCell);
|
|
994
|
+
}, [includeActiveCell]);
|
|
995
|
+
|
|
996
|
+
useEffect(() => {
|
|
997
|
+
// If the user enables "Include active cell" for the first time, default output to ON as well.
|
|
998
|
+
if (includeActiveCell && !includeActiveCellOutput && !hasStoredValue(INCLUDE_ACTIVE_CELL_OUTPUT_STORAGE_KEY)) {
|
|
999
|
+
setIncludeActiveCellOutput(true);
|
|
1000
|
+
}
|
|
1001
|
+
}, [includeActiveCell, includeActiveCellOutput]);
|
|
1002
|
+
|
|
1003
|
+
useEffect(() => {
|
|
1004
|
+
persistIncludeActiveCellOutput(includeActiveCellOutput);
|
|
1005
|
+
}, [includeActiveCellOutput]);
|
|
1006
|
+
|
|
1007
|
+
useEffect(() => {
|
|
1008
|
+
persistCommandPath(commandPath);
|
|
1009
|
+
}, [commandPath]);
|
|
1010
|
+
|
|
1011
|
+
useEffect(() => {
|
|
1012
|
+
persistReasoningEffort(reasoningEffort);
|
|
1013
|
+
}, [reasoningEffort]);
|
|
1014
|
+
|
|
1015
|
+
useEffect(() => {
|
|
1016
|
+
persistSandboxMode(sandboxMode);
|
|
1017
|
+
}, [sandboxMode]);
|
|
1018
|
+
|
|
1019
|
+
useEffect(() => {
|
|
1020
|
+
persistSettingsOpen(settingsOpen);
|
|
1021
|
+
}, [settingsOpen]);
|
|
1022
|
+
|
|
1023
|
+
useEffect(() => {
|
|
1024
|
+
notifyOnDoneRef.current = notifyOnDone;
|
|
1025
|
+
persistNotifyOnDone(notifyOnDone);
|
|
1026
|
+
}, [notifyOnDone]);
|
|
1027
|
+
|
|
1028
|
+
useEffect(() => {
|
|
1029
|
+
const normalized = Number.isFinite(notifyOnDoneMinSeconds) ? Math.max(0, Math.floor(notifyOnDoneMinSeconds)) : 0;
|
|
1030
|
+
notifyOnDoneMinSecondsRef.current = normalized;
|
|
1031
|
+
persistNotifyOnDoneMinSeconds(normalized);
|
|
1032
|
+
}, [notifyOnDoneMinSeconds]);
|
|
1033
|
+
|
|
1034
|
+
useEffect(() => {
|
|
1035
|
+
if (!modelMenuOpen && !reasoningMenuOpen && !usagePopoverOpen && !permissionMenuOpen && !contextPopoverOpen) {
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
1040
|
+
const target = event.target as Node | null;
|
|
1041
|
+
if (!target) {
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const inModel = modelMenuWrapRef.current?.contains(target) ?? false;
|
|
1046
|
+
const inReasoning = reasoningMenuWrapRef.current?.contains(target) ?? false;
|
|
1047
|
+
const inUsage = usageMenuWrapRef.current?.contains(target) ?? false;
|
|
1048
|
+
const inPermission = permissionMenuWrapRef.current?.contains(target) ?? false;
|
|
1049
|
+
const inContext = contextMenuWrapRef.current?.contains(target) ?? false;
|
|
1050
|
+
const inModelPopover = modelPopoverRef.current?.contains(target) ?? false;
|
|
1051
|
+
const inReasoningPopover = reasoningPopoverRef.current?.contains(target) ?? false;
|
|
1052
|
+
const inUsagePopover = usagePopoverRef.current?.contains(target) ?? false;
|
|
1053
|
+
const inPermissionPopover = permissionPopoverRef.current?.contains(target) ?? false;
|
|
1054
|
+
const inContextPopover = contextPopoverRef.current?.contains(target) ?? false;
|
|
1055
|
+
if (
|
|
1056
|
+
inModel ||
|
|
1057
|
+
inReasoning ||
|
|
1058
|
+
inUsage ||
|
|
1059
|
+
inPermission ||
|
|
1060
|
+
inContext ||
|
|
1061
|
+
inModelPopover ||
|
|
1062
|
+
inReasoningPopover ||
|
|
1063
|
+
inUsagePopover ||
|
|
1064
|
+
inPermissionPopover ||
|
|
1065
|
+
inContextPopover
|
|
1066
|
+
) {
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
setModelMenuOpen(false);
|
|
1071
|
+
setReasoningMenuOpen(false);
|
|
1072
|
+
setUsagePopoverOpen(false);
|
|
1073
|
+
setPermissionMenuOpen(false);
|
|
1074
|
+
setContextPopoverOpen(false);
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
1078
|
+
if (event.key !== 'Escape') {
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
event.preventDefault();
|
|
1082
|
+
setModelMenuOpen(false);
|
|
1083
|
+
setReasoningMenuOpen(false);
|
|
1084
|
+
setUsagePopoverOpen(false);
|
|
1085
|
+
setPermissionMenuOpen(false);
|
|
1086
|
+
setContextPopoverOpen(false);
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
window.addEventListener('pointerdown', onPointerDown, true);
|
|
1090
|
+
window.addEventListener('keydown', onKeyDown);
|
|
1091
|
+
return () => {
|
|
1092
|
+
window.removeEventListener('pointerdown', onPointerDown, true);
|
|
1093
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
1094
|
+
};
|
|
1095
|
+
}, [modelMenuOpen, reasoningMenuOpen, usagePopoverOpen, permissionMenuOpen, contextPopoverOpen]);
|
|
1096
|
+
|
|
1097
|
+
useEffect(() => {
|
|
1098
|
+
if (!selectionPopover) {
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
1103
|
+
const target = event.target as Node | null;
|
|
1104
|
+
if (!target) {
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const inAnchor = selectionPopoverAnchorRef.current?.contains(target) ?? false;
|
|
1109
|
+
const inPopover = selectionPopoverRef.current?.contains(target) ?? false;
|
|
1110
|
+
if (inAnchor || inPopover) {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
setSelectionPopover(null);
|
|
1115
|
+
selectionPopoverAnchorRef.current = null;
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
1119
|
+
if (event.key !== 'Escape') {
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
event.preventDefault();
|
|
1123
|
+
setSelectionPopover(null);
|
|
1124
|
+
selectionPopoverAnchorRef.current = null;
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
window.addEventListener('pointerdown', onPointerDown, true);
|
|
1128
|
+
window.addEventListener('keydown', onKeyDown);
|
|
1129
|
+
return () => {
|
|
1130
|
+
window.removeEventListener('pointerdown', onPointerDown, true);
|
|
1131
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
1132
|
+
};
|
|
1133
|
+
}, [selectionPopover]);
|
|
1134
|
+
|
|
1135
|
+
function closeSelectionPopover(): void {
|
|
1136
|
+
setSelectionPopover(null);
|
|
1137
|
+
selectionPopoverAnchorRef.current = null;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function clearCellAttachmentPopoverCloseTimer(): void {
|
|
1141
|
+
if (cellAttachmentPopoverCloseTimerRef.current !== null) {
|
|
1142
|
+
window.clearTimeout(cellAttachmentPopoverCloseTimerRef.current);
|
|
1143
|
+
cellAttachmentPopoverCloseTimerRef.current = null;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function clearContextPopoverCloseTimer(): void {
|
|
1148
|
+
if (contextPopoverCloseTimerRef.current !== null) {
|
|
1149
|
+
window.clearTimeout(contextPopoverCloseTimerRef.current);
|
|
1150
|
+
contextPopoverCloseTimerRef.current = null;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function openCellAttachmentPopover(): void {
|
|
1155
|
+
clearCellAttachmentPopoverCloseTimer();
|
|
1156
|
+
if (!showCellAttachmentBadge) {
|
|
1157
|
+
setCellAttachmentPopoverOpen(false);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
setCellAttachmentPopoverOpen(true);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function scheduleCloseCellAttachmentPopover(): void {
|
|
1164
|
+
clearCellAttachmentPopoverCloseTimer();
|
|
1165
|
+
cellAttachmentPopoverCloseTimerRef.current = window.setTimeout(() => {
|
|
1166
|
+
setCellAttachmentPopoverOpen(false);
|
|
1167
|
+
cellAttachmentPopoverCloseTimerRef.current = null;
|
|
1168
|
+
}, 90);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function openContextPopover(): void {
|
|
1172
|
+
clearContextPopoverCloseTimer();
|
|
1173
|
+
if (!hasContextUsageSnapshot) {
|
|
1174
|
+
setContextPopoverOpen(false);
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
setContextPopoverOpen(true);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function scheduleCloseContextPopover(): void {
|
|
1181
|
+
clearContextPopoverCloseTimer();
|
|
1182
|
+
contextPopoverCloseTimerRef.current = window.setTimeout(() => {
|
|
1183
|
+
setContextPopoverOpen(false);
|
|
1184
|
+
contextPopoverCloseTimerRef.current = null;
|
|
1185
|
+
}, 90);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function handleContextPopoverBlur(event: React.FocusEvent<HTMLDivElement>): void {
|
|
1189
|
+
const nextFocused = event.relatedTarget as Node | null;
|
|
1190
|
+
const inAnchor = nextFocused ? contextMenuWrapRef.current?.contains(nextFocused) ?? false : false;
|
|
1191
|
+
const inPopover = nextFocused ? contextPopoverRef.current?.contains(nextFocused) ?? false : false;
|
|
1192
|
+
if (inAnchor || inPopover) {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
scheduleCloseContextPopover();
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function handleCellAttachmentBlur(event: React.FocusEvent<HTMLDivElement>): void {
|
|
1199
|
+
const nextFocused = event.relatedTarget as Node | null;
|
|
1200
|
+
const inAnchor = nextFocused ? cellAttachmentAnchorRef.current?.contains(nextFocused) ?? false : false;
|
|
1201
|
+
const inPopover = nextFocused ? cellAttachmentPopoverRef.current?.contains(nextFocused) ?? false : false;
|
|
1202
|
+
if (inAnchor || inPopover) {
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
scheduleCloseCellAttachmentPopover();
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function toggleSelectionPopover(
|
|
1209
|
+
messageId: string,
|
|
1210
|
+
preview: MessageContextPreview,
|
|
1211
|
+
event: React.MouseEvent<HTMLButtonElement>
|
|
1212
|
+
): void {
|
|
1213
|
+
if (!messageId) {
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
if (selectionPopover?.messageId === messageId) {
|
|
1217
|
+
closeSelectionPopover();
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
selectionPopoverAnchorRef.current = event.currentTarget;
|
|
1221
|
+
setSelectionPopover({ messageId, preview });
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function commitStoredSelectionPreviews(
|
|
1225
|
+
nextByThread: Map<string, StoredSelectionPreviewEntry[]>
|
|
1226
|
+
): void {
|
|
1227
|
+
const trimmed = new Map<string, StoredSelectionPreviewEntry[]>();
|
|
1228
|
+
const entries = Array.from(nextByThread.entries()).slice(-MAX_STORED_SELECTION_PREVIEW_THREADS);
|
|
1229
|
+
for (const [threadId, threadEntries] of entries) {
|
|
1230
|
+
if (!threadId || !Array.isArray(threadEntries) || threadEntries.length <= 0) {
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
trimmed.set(threadId, threadEntries.slice(-MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD));
|
|
1234
|
+
}
|
|
1235
|
+
storedSelectionPreviewsRef.current = trimmed;
|
|
1236
|
+
persistStoredSelectionPreviewsByThread(trimmed);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function appendStoredSelectionPreviewEntry(
|
|
1240
|
+
threadId: string,
|
|
1241
|
+
content: string,
|
|
1242
|
+
preview: MessageContextPreview | undefined
|
|
1243
|
+
): void {
|
|
1244
|
+
const normalizedThreadId = (threadId || '').trim();
|
|
1245
|
+
if (!normalizedThreadId) {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
const hash = hashSelectionPreviewContent(content || '');
|
|
1249
|
+
if (!hash) {
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const next = new Map(storedSelectionPreviewsRef.current);
|
|
1254
|
+
const existing = next.get(normalizedThreadId) ?? [];
|
|
1255
|
+
const entry: StoredSelectionPreviewEntry = {
|
|
1256
|
+
contentHash: hash,
|
|
1257
|
+
preview: preview ?? null
|
|
1258
|
+
};
|
|
1259
|
+
next.delete(normalizedThreadId);
|
|
1260
|
+
next.set(
|
|
1261
|
+
normalizedThreadId,
|
|
1262
|
+
[...existing, entry].slice(-MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD)
|
|
1263
|
+
);
|
|
1264
|
+
commitStoredSelectionPreviews(next);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function migrateStoredSelectionPreviewEntries(fromThreadId: string, toThreadId: string): void {
|
|
1268
|
+
const from = (fromThreadId || '').trim();
|
|
1269
|
+
const to = (toThreadId || '').trim();
|
|
1270
|
+
if (!from || !to || from === to) {
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const current = storedSelectionPreviewsRef.current;
|
|
1275
|
+
const sourceEntries = current.get(from);
|
|
1276
|
+
if (!sourceEntries || sourceEntries.length <= 0) {
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const next = new Map(current);
|
|
1281
|
+
const targetEntries = next.get(to) ?? [];
|
|
1282
|
+
next.delete(from);
|
|
1283
|
+
next.delete(to);
|
|
1284
|
+
next.set(
|
|
1285
|
+
to,
|
|
1286
|
+
[...targetEntries, ...sourceEntries].slice(-MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD)
|
|
1287
|
+
);
|
|
1288
|
+
commitStoredSelectionPreviews(next);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function clearStoredSelectionPreviews(): void {
|
|
1292
|
+
storedSelectionPreviewsRef.current = new Map();
|
|
1293
|
+
safeLocalStorageRemove(SELECTION_PREVIEWS_STORAGE_KEY);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function replaceSessions(next: Map<string, NotebookSession>): void {
|
|
1297
|
+
sessionsRef.current = next;
|
|
1298
|
+
setSessions(next);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function trimSessionMessages(messages: ChatEntry[]): ChatEntry[] {
|
|
1302
|
+
if (messages.length <= MAX_SESSION_MESSAGES) {
|
|
1303
|
+
return messages;
|
|
1304
|
+
}
|
|
1305
|
+
return messages.slice(messages.length - MAX_SESSION_MESSAGES);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function updateSessions(
|
|
1309
|
+
updater: (prev: Map<string, NotebookSession>) => Map<string, NotebookSession>
|
|
1310
|
+
): void {
|
|
1311
|
+
setSessions(prev => {
|
|
1312
|
+
const next = updater(prev);
|
|
1313
|
+
sessionsRef.current = next;
|
|
1314
|
+
return next;
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function ensureSession(path: string, sessionKey?: string): NotebookSession {
|
|
1319
|
+
const normalizedPath = path || '';
|
|
1320
|
+
const effectiveSessionKey = sessionKey || resolveCurrentSessionKey(normalizedPath);
|
|
1321
|
+
const existing = sessionsRef.current.get(effectiveSessionKey);
|
|
1322
|
+
if (existing) {
|
|
1323
|
+
return existing;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
const created = createSession(normalizedPath, `Session started`, { sessionKey: effectiveSessionKey });
|
|
1327
|
+
const seeded: NotebookSession = {
|
|
1328
|
+
...created,
|
|
1329
|
+
selectedModelOption: modelOption,
|
|
1330
|
+
selectedReasoningEffort: reasoningEffort,
|
|
1331
|
+
selectedSandboxMode: sandboxMode,
|
|
1332
|
+
};
|
|
1333
|
+
const next = new Map(sessionsRef.current);
|
|
1334
|
+
next.set(effectiveSessionKey, seeded);
|
|
1335
|
+
activeSessionKeyByPathRef.current.set(normalizedPath, effectiveSessionKey);
|
|
1336
|
+
replaceSessions(next);
|
|
1337
|
+
return seeded;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function updateSessionSelection(
|
|
1341
|
+
sessionKey: string,
|
|
1342
|
+
selection: Partial<
|
|
1343
|
+
Pick<
|
|
1344
|
+
NotebookSession,
|
|
1345
|
+
'selectedModelOption' | 'selectedReasoningEffort' | 'selectedSandboxMode' | 'effectiveSandboxMode'
|
|
1346
|
+
>
|
|
1347
|
+
>
|
|
1348
|
+
): void {
|
|
1349
|
+
const targetSessionKey = sessionKey || currentNotebookSessionKeyRef.current || '';
|
|
1350
|
+
if (!targetSessionKey) {
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
updateSessions(prev => {
|
|
1354
|
+
const next = new Map(prev);
|
|
1355
|
+
const existing =
|
|
1356
|
+
next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
|
|
1357
|
+
const selectedModelOption = selection.selectedModelOption ?? existing.selectedModelOption;
|
|
1358
|
+
const selectedReasoningEffort =
|
|
1359
|
+
selection.selectedReasoningEffort ?? existing.selectedReasoningEffort;
|
|
1360
|
+
const selectedSandboxMode = selection.selectedSandboxMode ?? existing.selectedSandboxMode;
|
|
1361
|
+
const effectiveSandboxMode =
|
|
1362
|
+
selection.effectiveSandboxMode ?? existing.effectiveSandboxMode;
|
|
1363
|
+
if (
|
|
1364
|
+
selectedModelOption === existing.selectedModelOption &&
|
|
1365
|
+
selectedReasoningEffort === existing.selectedReasoningEffort &&
|
|
1366
|
+
selectedSandboxMode === existing.selectedSandboxMode &&
|
|
1367
|
+
effectiveSandboxMode === existing.effectiveSandboxMode
|
|
1368
|
+
) {
|
|
1369
|
+
return prev;
|
|
1370
|
+
}
|
|
1371
|
+
next.set(targetSessionKey, {
|
|
1372
|
+
...existing,
|
|
1373
|
+
selectedModelOption,
|
|
1374
|
+
selectedReasoningEffort,
|
|
1375
|
+
selectedSandboxMode,
|
|
1376
|
+
effectiveSandboxMode,
|
|
1377
|
+
});
|
|
1378
|
+
return next;
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function setCurrentSessionModelOption(nextValue: ModelOptionValue): void {
|
|
1383
|
+
setModelOption(nextValue);
|
|
1384
|
+
updateSessionSelection(currentNotebookSessionKeyRef.current || '', { selectedModelOption: nextValue });
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function setCurrentSessionReasoningEffort(nextValue: ReasoningOptionValue): void {
|
|
1388
|
+
setReasoningEffort(nextValue);
|
|
1389
|
+
updateSessionSelection(currentNotebookSessionKeyRef.current || '', { selectedReasoningEffort: nextValue });
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function setCurrentSessionSandboxMode(nextValue: SandboxMode): void {
|
|
1393
|
+
setSandboxMode(nextValue);
|
|
1394
|
+
updateSessionSelection(currentNotebookSessionKeyRef.current || '', { selectedSandboxMode: nextValue });
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function requestRateLimitsRefresh(force = false): void {
|
|
1398
|
+
const socket = wsRef.current;
|
|
1399
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const now = Date.now();
|
|
1404
|
+
if (!force && now - lastRateLimitsRefreshRef.current < 15000) {
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
lastRateLimitsRefreshRef.current = now;
|
|
1408
|
+
safeSocketSend(JSON.stringify({ type: 'refresh_rate_limits' }));
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function safeSocketSend(payload: string): boolean {
|
|
1412
|
+
const socket = wsRef.current;
|
|
1413
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
1414
|
+
return false;
|
|
1415
|
+
}
|
|
1416
|
+
try {
|
|
1417
|
+
socket.send(payload);
|
|
1418
|
+
} catch {
|
|
1419
|
+
return false;
|
|
1420
|
+
}
|
|
1421
|
+
return true;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function toggleUsagePopover(): void {
|
|
1425
|
+
setUsagePopoverOpen(open => {
|
|
1426
|
+
const next = !open;
|
|
1427
|
+
if (next) {
|
|
1428
|
+
requestRateLimitsRefresh();
|
|
1429
|
+
}
|
|
1430
|
+
return next;
|
|
1431
|
+
});
|
|
1432
|
+
setModelMenuOpen(false);
|
|
1433
|
+
setReasoningMenuOpen(false);
|
|
1434
|
+
setPermissionMenuOpen(false);
|
|
1435
|
+
setContextPopoverOpen(false);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
async function updateNotifyOnDone(enabled: boolean): Promise<void> {
|
|
1439
|
+
if (!enabled) {
|
|
1440
|
+
setNotifyOnDone(false);
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const permission = getBrowserNotificationPermission();
|
|
1445
|
+
const systemSessionKey = currentNotebookSessionKeyRef.current || '';
|
|
1446
|
+
if (permission === 'unsupported') {
|
|
1447
|
+
appendMessage(systemSessionKey, 'system', 'Browser notifications are not supported in this environment.');
|
|
1448
|
+
setNotifyOnDone(false);
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
if (permission === 'granted') {
|
|
1452
|
+
setNotifyOnDone(true);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
if (permission === 'denied') {
|
|
1456
|
+
appendMessage(
|
|
1457
|
+
systemSessionKey,
|
|
1458
|
+
'system',
|
|
1459
|
+
'Browser notifications are blocked for this site. Allow notifications in browser settings to enable this option.'
|
|
1460
|
+
);
|
|
1461
|
+
setNotifyOnDone(false);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
try {
|
|
1466
|
+
const requested = await window.Notification.requestPermission();
|
|
1467
|
+
if (requested === 'granted') {
|
|
1468
|
+
setNotifyOnDone(true);
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
if (requested === 'denied') {
|
|
1472
|
+
appendMessage(
|
|
1473
|
+
systemSessionKey,
|
|
1474
|
+
'system',
|
|
1475
|
+
'Browser notification permission was denied. Allow notifications in browser settings to enable this option.'
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
setNotifyOnDone(false);
|
|
1479
|
+
} catch {
|
|
1480
|
+
appendMessage(systemSessionKey, 'system', 'Failed to request browser notification permission.');
|
|
1481
|
+
setNotifyOnDone(false);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function getRunDurationMs(sessionKey: string): number | null {
|
|
1486
|
+
if (!sessionKey) {
|
|
1487
|
+
return null;
|
|
1488
|
+
}
|
|
1489
|
+
const session = sessionsRef.current.get(sessionKey);
|
|
1490
|
+
if (!session || typeof session.runStartedAt !== 'number' || !Number.isFinite(session.runStartedAt)) {
|
|
1491
|
+
return null;
|
|
1492
|
+
}
|
|
1493
|
+
return Math.max(0, Date.now() - session.runStartedAt);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function formatElapsedForNotification(elapsedMs: number): string {
|
|
1497
|
+
const totalSeconds = Math.round(Math.max(0, elapsedMs) / 1000);
|
|
1498
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
1499
|
+
const seconds = totalSeconds % 60;
|
|
1500
|
+
if (minutes === 0) {
|
|
1501
|
+
return `${totalSeconds}s`;
|
|
1502
|
+
}
|
|
1503
|
+
return `${minutes}m ${seconds}s`;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function notifyRunDone(sessionKey: string, notebookPath: string, cancelled: boolean, exitCode: number | null): void {
|
|
1507
|
+
if (!notifyOnDoneRef.current) {
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
const permission = getBrowserNotificationPermission();
|
|
1511
|
+
if (permission !== 'granted' || typeof window === 'undefined' || typeof window.Notification === 'undefined') {
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const elapsedMs = getRunDurationMs(sessionKey);
|
|
1516
|
+
const minimumMs = notifyOnDoneMinSecondsRef.current * 1000;
|
|
1517
|
+
if (minimumMs > 0 && (elapsedMs === null || elapsedMs < minimumMs)) {
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const parsed = parseSessionKey(sessionKey);
|
|
1522
|
+
const pathLabel = parsed.path || notebookPath || currentNotebookPathRef.current || 'current notebook';
|
|
1523
|
+
const pathSummary = truncateMiddle(pathLabel, 120);
|
|
1524
|
+
const elapsedText = elapsedMs === null ? '' : ` (${formatElapsedForNotification(elapsedMs)} elapsed)`;
|
|
1525
|
+
const body = cancelled
|
|
1526
|
+
? `Run cancelled in ${pathSummary}${elapsedText}`
|
|
1527
|
+
: exitCode === null || exitCode === 0
|
|
1528
|
+
? `Run completed in ${pathSummary}${elapsedText}`
|
|
1529
|
+
: `Run failed (exit ${exitCode}) in ${pathSummary}${elapsedText}`;
|
|
1530
|
+
|
|
1531
|
+
try {
|
|
1532
|
+
const notification = new window.Notification('Codex run finished', {
|
|
1533
|
+
body,
|
|
1534
|
+
tag: sessionKey ? `codex-done-${sessionKey}` : undefined
|
|
1535
|
+
});
|
|
1536
|
+
window.setTimeout(() => notification.close(), 12000);
|
|
1537
|
+
} catch {
|
|
1538
|
+
// Ignore failures; completion is still shown in the panel.
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function emitSessionThreadEvent(sessionKey: string, notebookPath: string, threadId: string): void {
|
|
1543
|
+
const source = sessionThreadSyncIdRef.current;
|
|
1544
|
+
const payload: SessionThreadSyncEvent = buildSessionThreadSyncEvent({
|
|
1545
|
+
sessionKey,
|
|
1546
|
+
notebookPath,
|
|
1547
|
+
threadId,
|
|
1548
|
+
source,
|
|
1549
|
+
createEventId: createSessionEventId
|
|
1550
|
+
});
|
|
1551
|
+
writeSessionThreadSyncEvent(payload);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function sendStartSession(
|
|
1555
|
+
session: NotebookSession,
|
|
1556
|
+
notebookPath: string,
|
|
1557
|
+
sessionKey: string,
|
|
1558
|
+
options?: { forceNewThread?: boolean }
|
|
1559
|
+
): void {
|
|
1560
|
+
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
const normalizedCommandPath = commandPath.trim();
|
|
1564
|
+
safeSocketSend(
|
|
1565
|
+
JSON.stringify(
|
|
1566
|
+
buildStartSessionMessage({
|
|
1567
|
+
sessionId: session.threadId,
|
|
1568
|
+
sessionContextKey: sessionKey,
|
|
1569
|
+
notebookPath,
|
|
1570
|
+
forceNewThread: options?.forceNewThread === true,
|
|
1571
|
+
commandPath: normalizedCommandPath || undefined
|
|
1572
|
+
})
|
|
1573
|
+
)
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function syncEffectiveSandboxFromStatus(sessionKey: string, rawMode: unknown): void {
|
|
1578
|
+
if (!sessionKey) {
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
const nextMode = coerceSandboxMode(rawMode);
|
|
1582
|
+
if (!nextMode) {
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
updateSessionSelection(sessionKey, { effectiveSandboxMode: nextMode });
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function deleteAllSessionsOnServer(): boolean {
|
|
1589
|
+
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
|
1590
|
+
return false;
|
|
1591
|
+
}
|
|
1592
|
+
const sent = safeSocketSend(JSON.stringify(buildDeleteAllSessionsMessage()));
|
|
1593
|
+
if (sent) {
|
|
1594
|
+
return true;
|
|
1595
|
+
}
|
|
1596
|
+
return false;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function setSessionRunState(sessionKey: string, runState: RunState, runId: string | null): void {
|
|
1600
|
+
if (!sessionKey) {
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
const targetSessionKey = sessionKey.trim();
|
|
1604
|
+
const isRunStart = runState === 'running';
|
|
1605
|
+
const isRunDone = runState === 'ready';
|
|
1606
|
+
let shouldClearPythonRunState = false;
|
|
1607
|
+
|
|
1608
|
+
const now = Date.now();
|
|
1609
|
+
updateSessions(prev => {
|
|
1610
|
+
const next = new Map(prev);
|
|
1611
|
+
const existing = next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
|
|
1612
|
+
const session = next.get(targetSessionKey) ?? existing;
|
|
1613
|
+
let messages = session.messages;
|
|
1614
|
+
let runStartedAt = session.runStartedAt;
|
|
1615
|
+
let activeRunId = session.activeRunId;
|
|
1616
|
+
const wasRunning = session.runState === 'running';
|
|
1617
|
+
const hasActiveRunId = Boolean(activeRunId && activeRunId.trim());
|
|
1618
|
+
|
|
1619
|
+
if (isRunStart && !runId && wasRunning && hasActiveRunId) {
|
|
1620
|
+
return prev;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
if (isRunDone && wasRunning && hasActiveRunId) {
|
|
1624
|
+
if (!runId || runId !== activeRunId) {
|
|
1625
|
+
return prev;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
if (isRunStart) {
|
|
1630
|
+
if (!wasRunning) {
|
|
1631
|
+
runStartedAt = now;
|
|
1632
|
+
}
|
|
1633
|
+
if (runId) {
|
|
1634
|
+
activeRunId = runId;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
if (isRunDone && wasRunning) {
|
|
1638
|
+
const startedAt = runStartedAt;
|
|
1639
|
+
if (typeof startedAt === 'number' && Number.isFinite(startedAt)) {
|
|
1640
|
+
const elapsedMs = Math.max(0, now - startedAt);
|
|
1641
|
+
messages = [...messages, { kind: 'run-divider', id: crypto.randomUUID(), elapsedMs }];
|
|
1642
|
+
}
|
|
1643
|
+
runStartedAt = null;
|
|
1644
|
+
activeRunId = null;
|
|
1645
|
+
shouldClearPythonRunState = true;
|
|
1646
|
+
} else if (isRunDone) {
|
|
1647
|
+
activeRunId = null;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
const progress = session.runState === runState ? session.progress : '';
|
|
1651
|
+
const progressKind = session.runState === runState ? session.progressKind : '';
|
|
1652
|
+
next.set(sessionKey, {
|
|
1653
|
+
...session,
|
|
1654
|
+
messages: trimSessionMessages(messages),
|
|
1655
|
+
runState,
|
|
1656
|
+
activeRunId,
|
|
1657
|
+
runStartedAt,
|
|
1658
|
+
progress,
|
|
1659
|
+
progressKind
|
|
1660
|
+
});
|
|
1661
|
+
return next;
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
if (isRunStart && plainPyRunSessionKeyRef.current === targetSessionKey) {
|
|
1665
|
+
setIsPlainPyRunInProgress(true);
|
|
1666
|
+
} else if (isRunDone && shouldClearPythonRunState && plainPyRunSessionKeyRef.current === targetSessionKey) {
|
|
1667
|
+
plainPyRunSessionKeyRef.current = '';
|
|
1668
|
+
setIsPlainPyRunInProgress(false);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function setSessionProgress(sessionKey: string, progress: string, kind: ProgressKind = ''): void {
|
|
1673
|
+
const targetSessionKey = sessionKey || currentNotebookSessionKeyRef.current || '';
|
|
1674
|
+
if (!targetSessionKey) {
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
const nextProgress = progress ? truncateMiddle(progress, 260) : '';
|
|
1679
|
+
const nextKind: ProgressKind = nextProgress ? kind : '';
|
|
1680
|
+
updateSessions(prev => {
|
|
1681
|
+
const next = new Map(prev);
|
|
1682
|
+
const session =
|
|
1683
|
+
next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
|
|
1684
|
+
if (session.progress === nextProgress && session.progressKind === nextKind) {
|
|
1685
|
+
return prev;
|
|
1686
|
+
}
|
|
1687
|
+
next.set(targetSessionKey, { ...session, progress: nextProgress, progressKind: nextKind });
|
|
1688
|
+
return next;
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function appendActivityItem(sessionKey: string, item: Omit<ActivityItem, 'id' | 'ts'>): void {
|
|
1693
|
+
const targetSessionKey = sessionKey || currentNotebookSessionKeyRef.current || '';
|
|
1694
|
+
if (!targetSessionKey) {
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
updateSessions(prev => {
|
|
1699
|
+
const next = new Map(prev);
|
|
1700
|
+
const session =
|
|
1701
|
+
next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
|
|
1702
|
+
const entry: ActivityItem = { id: crypto.randomUUID(), ts: Date.now(), ...item };
|
|
1703
|
+
const extractCommandKey = (detail: string): string => {
|
|
1704
|
+
const raw = (detail || '').trim();
|
|
1705
|
+
if (!raw) {
|
|
1706
|
+
return '';
|
|
1707
|
+
}
|
|
1708
|
+
// Completed entries may include extra lines (e.g. exit code).
|
|
1709
|
+
return raw.split('\n')[0].trim();
|
|
1710
|
+
};
|
|
1711
|
+
const normalizePhaseBaseTitle = (title: string): string => {
|
|
1712
|
+
const raw = (title || '').trim();
|
|
1713
|
+
if (!raw) {
|
|
1714
|
+
return '';
|
|
1715
|
+
}
|
|
1716
|
+
return raw.replace(/\s+(started|completed)\s*$/i, '').trim();
|
|
1717
|
+
};
|
|
1718
|
+
const extractGenericKey = (title: string, detail: string): string => {
|
|
1719
|
+
const base = normalizePhaseBaseTitle(title);
|
|
1720
|
+
const firstLine = (detail || '').trim().split('\n')[0].trim();
|
|
1721
|
+
return `${base}::${firstLine}`;
|
|
1722
|
+
};
|
|
1723
|
+
|
|
1724
|
+
const messages = session.messages;
|
|
1725
|
+
|
|
1726
|
+
// Avoid noisy duplicates like repeated "Reasoning step" lines.
|
|
1727
|
+
if (entry.category === 'reasoning') {
|
|
1728
|
+
const last = messages[messages.length - 1];
|
|
1729
|
+
if (last && last.kind === 'activity') {
|
|
1730
|
+
const previousItem = last.item;
|
|
1731
|
+
if (
|
|
1732
|
+
previousItem.category === entry.category &&
|
|
1733
|
+
previousItem.phase === entry.phase &&
|
|
1734
|
+
previousItem.title === entry.title &&
|
|
1735
|
+
previousItem.detail === entry.detail
|
|
1736
|
+
) {
|
|
1737
|
+
return prev;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// If we have a corresponding "started" command, update it in place instead of appending.
|
|
1743
|
+
if (entry.category === 'command' && entry.phase === 'completed') {
|
|
1744
|
+
const key = extractCommandKey(entry.detail);
|
|
1745
|
+
if (key) {
|
|
1746
|
+
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
|
|
1747
|
+
const msg = messages[idx];
|
|
1748
|
+
if (msg.kind !== 'activity') {
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1751
|
+
const existing = msg.item;
|
|
1752
|
+
if (existing.category !== 'command' || existing.phase !== 'started') {
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
if (extractCommandKey(existing.detail) !== key) {
|
|
1756
|
+
continue;
|
|
1757
|
+
}
|
|
1758
|
+
const updated: ActivityItem = {
|
|
1759
|
+
...existing,
|
|
1760
|
+
phase: 'completed',
|
|
1761
|
+
title: entry.title,
|
|
1762
|
+
detail: entry.detail,
|
|
1763
|
+
raw: entry.raw,
|
|
1764
|
+
};
|
|
1765
|
+
const updatedMessages: ChatEntry[] = [
|
|
1766
|
+
...messages.slice(0, idx),
|
|
1767
|
+
{ ...msg, item: updated },
|
|
1768
|
+
...messages.slice(idx + 1),
|
|
1769
|
+
];
|
|
1770
|
+
next.set(targetSessionKey, { ...session, messages: trimSessionMessages(updatedMessages) });
|
|
1771
|
+
return next;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Generic: If we have a corresponding "started" tool/event line, update it in place.
|
|
1777
|
+
// This keeps pairs like "Web Search started" -> "Web Search completed" on a single line.
|
|
1778
|
+
if (entry.phase === 'completed') {
|
|
1779
|
+
const key = extractGenericKey(entry.title, entry.detail);
|
|
1780
|
+
if (key) {
|
|
1781
|
+
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
|
|
1782
|
+
const msg = messages[idx];
|
|
1783
|
+
if (msg.kind !== 'activity') {
|
|
1784
|
+
continue;
|
|
1785
|
+
}
|
|
1786
|
+
const existing = msg.item;
|
|
1787
|
+
if (existing.phase !== 'started') {
|
|
1788
|
+
continue;
|
|
1789
|
+
}
|
|
1790
|
+
if (extractGenericKey(existing.title, existing.detail) !== key) {
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
const updated: ActivityItem = {
|
|
1794
|
+
...existing,
|
|
1795
|
+
phase: 'completed',
|
|
1796
|
+
title: entry.title,
|
|
1797
|
+
detail: entry.detail,
|
|
1798
|
+
raw: entry.raw,
|
|
1799
|
+
};
|
|
1800
|
+
const updatedMessages: ChatEntry[] = [
|
|
1801
|
+
...messages.slice(0, idx),
|
|
1802
|
+
{ ...msg, item: updated },
|
|
1803
|
+
...messages.slice(idx + 1),
|
|
1804
|
+
];
|
|
1805
|
+
next.set(targetSessionKey, { ...session, messages: trimSessionMessages(updatedMessages) });
|
|
1806
|
+
return next;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
const updatedMessages: ChatEntry[] = [...messages, { kind: 'activity', id: entry.id, item: entry }];
|
|
1812
|
+
next.set(targetSessionKey, { ...session, messages: trimSessionMessages(updatedMessages) });
|
|
1813
|
+
return next;
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
function setSessionPairing(
|
|
1818
|
+
sessionKey: string,
|
|
1819
|
+
pairing: {
|
|
1820
|
+
pairedOk: boolean | null;
|
|
1821
|
+
pairedPath: string;
|
|
1822
|
+
pairedOsPath: string;
|
|
1823
|
+
pairedMessage: string;
|
|
1824
|
+
notebookMode: NotebookMode | null;
|
|
1825
|
+
}
|
|
1826
|
+
): void {
|
|
1827
|
+
const targetSessionKey = sessionKey || currentNotebookSessionKeyRef.current || '';
|
|
1828
|
+
if (!targetSessionKey) {
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
updateSessions(prev => {
|
|
1833
|
+
const next = new Map(prev);
|
|
1834
|
+
const session =
|
|
1835
|
+
next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
|
|
1836
|
+
next.set(targetSessionKey, {
|
|
1837
|
+
...session,
|
|
1838
|
+
...pairing,
|
|
1839
|
+
notebookMode: pairing.notebookMode ?? session.notebookMode,
|
|
1840
|
+
});
|
|
1841
|
+
return next;
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function setSessionConversationMode(sessionKey: string, rawMode: unknown): void {
|
|
1846
|
+
const mode = coerceConversationMode(rawMode);
|
|
1847
|
+
if (!mode) {
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
const targetSessionKey = sessionKey || currentNotebookSessionKeyRef.current || '';
|
|
1851
|
+
if (!targetSessionKey) {
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
updateSessions(prev => {
|
|
1856
|
+
const next = new Map(prev);
|
|
1857
|
+
const session =
|
|
1858
|
+
next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
|
|
1859
|
+
if (session.conversationMode === mode) {
|
|
1860
|
+
return prev;
|
|
1861
|
+
}
|
|
1862
|
+
next.set(targetSessionKey, { ...session, conversationMode: mode });
|
|
1863
|
+
return next;
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function resolveMessageSessionKey(msg: any): string {
|
|
1868
|
+
return resolveMessageSessionKeyForMessage({
|
|
1869
|
+
message: msg,
|
|
1870
|
+
runToSessionKey: runToSessionKeyRef.current,
|
|
1871
|
+
activeSessionKeyByPath: activeSessionKeyByPathRef.current,
|
|
1872
|
+
currentSessionKey: currentNotebookSessionKeyRef.current || ''
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function appendMessage(sessionKey: string, role: TextRole, text: string): void {
|
|
1877
|
+
if (!text) {
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
const targetSessionKey = sessionKey || currentNotebookSessionKeyRef.current || '';
|
|
1881
|
+
if (!targetSessionKey) {
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
const nextText = normalizeSystemText(role, text);
|
|
1885
|
+
|
|
1886
|
+
updateSessions(prev => {
|
|
1887
|
+
const next = new Map(prev);
|
|
1888
|
+
const session =
|
|
1889
|
+
next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
|
|
1890
|
+
const messages = session.messages;
|
|
1891
|
+
const updatedMessages: ChatEntry[] = [
|
|
1892
|
+
...messages,
|
|
1893
|
+
{ kind: 'text', id: crypto.randomUUID(), role, text: nextText }
|
|
1894
|
+
];
|
|
1895
|
+
|
|
1896
|
+
next.set(targetSessionKey, { ...session, messages: trimSessionMessages(updatedMessages) });
|
|
1897
|
+
return next;
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
function scrollToBottom(): void {
|
|
1902
|
+
endRef.current?.scrollIntoView({ block: 'end' });
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function onScrollMessages(): void {
|
|
1906
|
+
const node = scrollRef.current;
|
|
1907
|
+
if (!node) {
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
const threshold = 80;
|
|
1911
|
+
const atBottom = node.scrollHeight - (node.scrollTop + node.clientHeight) < threshold;
|
|
1912
|
+
setIsAtBottom(atBottom);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
function clearRunMappingForSessionKey(targetSessionKey: string): void {
|
|
1916
|
+
const runToSessionKey = runToSessionKeyRef.current;
|
|
1917
|
+
const next = new Map(runToSessionKey);
|
|
1918
|
+
for (const [runId, mappedSessionKey] of runToSessionKey) {
|
|
1919
|
+
if (mappedSessionKey === targetSessionKey) {
|
|
1920
|
+
next.delete(runId);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
runToSessionKeyRef.current = next;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
async function clearAllSessions(): Promise<void> {
|
|
1927
|
+
const count = getStoredSessionThreadCount();
|
|
1928
|
+
if (!count && sessions.size === 0) {
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const result = await showDialog({
|
|
1933
|
+
title: 'Delete all conversations',
|
|
1934
|
+
body: `This will delete ${count} saved conversation(s) and all in-memory messages in this panel.`,
|
|
1935
|
+
buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Delete' })]
|
|
1936
|
+
});
|
|
1937
|
+
if (!result.button.accept) {
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
const activeSessionKey = currentNotebookSessionKeyRef.current || '';
|
|
1942
|
+
if (activeSessionKey) {
|
|
1943
|
+
clearRunMappingForSessionKey(activeSessionKey);
|
|
1944
|
+
}
|
|
1945
|
+
markDeleteAllPending();
|
|
1946
|
+
if (!deleteAllSessionsOnServer() && activeSessionKey) {
|
|
1947
|
+
appendMessage(activeSessionKey, 'system', 'Delete request could not be sent now. It will be retried when you reconnect.');
|
|
1948
|
+
}
|
|
1949
|
+
runToSessionKeyRef.current = new Map();
|
|
1950
|
+
activeSessionKeyByPathRef.current = new Map();
|
|
1951
|
+
lastActiveCellAttachmentSignatureRef.current = new Map();
|
|
1952
|
+
safeLocalStorageRemove(STORAGE_KEY_SESSION_THREADS);
|
|
1953
|
+
clearStoredSelectionPreviews();
|
|
1954
|
+
replaceSessions(new Map());
|
|
1955
|
+
clearInputForCurrentSession();
|
|
1956
|
+
clearPendingImages();
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
async function refreshNotebook(sessionKey: string): Promise<void> {
|
|
1960
|
+
const { path } = parseSessionKey(sessionKey);
|
|
1961
|
+
if (!path) {
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
const activeWidget = getActiveDocumentWidget(props.app, activeDocumentWidgetRef.current);
|
|
1966
|
+
const activePath = getSupportedDocumentPath(activeWidget);
|
|
1967
|
+
const isSessionStillRunning = sessionsRef.current.get(sessionKey)?.runState === 'running';
|
|
1968
|
+
if (isSessionStillRunning && activePath !== path) {
|
|
1969
|
+
pendingRefreshPathsRef.current.add(sessionKey);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
const widget = findDocumentWidgetByPath(props.app, path, activeDocumentWidgetRef.current);
|
|
1974
|
+
if (!widget) {
|
|
1975
|
+
pendingRefreshPathsRef.current.add(sessionKey);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
const context: any = getDocumentContext(widget);
|
|
1979
|
+
if (!context || typeof context.revert !== 'function') {
|
|
1980
|
+
pendingRefreshPathsRef.current.add(sessionKey);
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
activeDocumentWidgetRef.current = widget;
|
|
1984
|
+
|
|
1985
|
+
if (context.model.dirty) {
|
|
1986
|
+
pendingRefreshPathsRef.current.add(sessionKey);
|
|
1987
|
+
appendMessage(
|
|
1988
|
+
sessionKey,
|
|
1989
|
+
'system',
|
|
1990
|
+
'Codex updated files, but this document has unsaved changes. Reload manually when ready.'
|
|
1991
|
+
);
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
const viewState = captureDocumentViewState(widget);
|
|
1996
|
+
try {
|
|
1997
|
+
await context.revert();
|
|
1998
|
+
restoreDocumentViewState(widget, viewState);
|
|
1999
|
+
appendMessage(sessionKey, 'system', 'Document refreshed due to file changes.');
|
|
2000
|
+
pendingRefreshPathsRef.current.delete(sessionKey);
|
|
2001
|
+
} catch (err) {
|
|
2002
|
+
appendMessage(sessionKey, 'system', `Failed to refresh document: ${String(err)}`);
|
|
2003
|
+
pendingRefreshPathsRef.current.add(sessionKey);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
useEffect(() => {
|
|
2008
|
+
const updateNotebook = () => {
|
|
2009
|
+
const activeWidget = getActiveDocumentWidget(props.app, activeDocumentWidgetRef.current);
|
|
2010
|
+
if (activeWidget) {
|
|
2011
|
+
activeDocumentWidgetRef.current = activeWidget;
|
|
2012
|
+
}
|
|
2013
|
+
const nextIsNotebookEditor = isNotebookWidget(activeWidget);
|
|
2014
|
+
setCurrentDocumentIsNotebookEditor(prev => (prev === nextIsNotebookEditor ? prev : nextIsNotebookEditor));
|
|
2015
|
+
const path = getSupportedDocumentPath(activeWidget);
|
|
2016
|
+
const sessionKey = resolveSessionKey(path);
|
|
2017
|
+
const previousKey = currentNotebookSessionKeyRef.current;
|
|
2018
|
+
if (sessionKey && sessionKey === previousKey) {
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
if (previousKey) {
|
|
2023
|
+
saveCurrentInputDraft(inputRef.current);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
currentNotebookPathRef.current = path;
|
|
2027
|
+
setCurrentNotebookPath(path);
|
|
2028
|
+
setCurrentNotebookSessionKey(sessionKey);
|
|
2029
|
+
currentNotebookSessionKeyRef.current = sessionKey;
|
|
2030
|
+
if (!sessionKey) {
|
|
2031
|
+
clearInputForCurrentSession();
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
restoreInput(sessionKey);
|
|
2035
|
+
clearPendingImages();
|
|
2036
|
+
setIsAtBottom(true);
|
|
2037
|
+
|
|
2038
|
+
if (!path) {
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
const session = ensureSession(path, sessionKey);
|
|
2043
|
+
activeSessionKeyByPathRef.current.set(path, sessionKey);
|
|
2044
|
+
sendStartSession(session, path, sessionKey);
|
|
2045
|
+
if (pendingRefreshPathsRef.current.has(sessionKey)) {
|
|
2046
|
+
void refreshNotebook(sessionKey);
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
};
|
|
2050
|
+
|
|
2051
|
+
updateNotebook();
|
|
2052
|
+
const shellChanged = props.app.shell.currentChanged;
|
|
2053
|
+
if (shellChanged) {
|
|
2054
|
+
shellChanged.connect(updateNotebook);
|
|
2055
|
+
}
|
|
2056
|
+
props.notebooks.currentChanged.connect(updateNotebook);
|
|
2057
|
+
|
|
2058
|
+
return () => {
|
|
2059
|
+
if (shellChanged) {
|
|
2060
|
+
shellChanged.disconnect(updateNotebook);
|
|
2061
|
+
}
|
|
2062
|
+
props.notebooks.currentChanged.disconnect(updateNotebook);
|
|
2063
|
+
};
|
|
2064
|
+
}, [props.app, props.notebooks]);
|
|
2065
|
+
|
|
2066
|
+
useEffect(() => {
|
|
2067
|
+
const onStorage = (event: StorageEvent) => {
|
|
2068
|
+
if (event.key !== STORAGE_KEY_SESSION_THREADS_EVENT || !event.newValue) {
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
const syncEvent = coerceSessionThreadSyncEvent(event.newValue);
|
|
2073
|
+
if (!syncEvent) {
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
if (syncEvent.source === sessionThreadSyncIdRef.current) {
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
const notebookPath = syncEvent.notebookPath;
|
|
2081
|
+
const sessionKey = syncEvent.sessionKey;
|
|
2082
|
+
const threadId = syncEvent.threadId;
|
|
2083
|
+
if (!notebookPath || !sessionKey || !threadId) {
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
const resolvedSessionKey = resolveSessionKey(notebookPath);
|
|
2088
|
+
if (!resolvedSessionKey || resolvedSessionKey !== sessionKey) {
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
const resetSession = createThreadResetSession(notebookPath, sessionKey, threadId);
|
|
2093
|
+
const currentPath = currentNotebookPathRef.current;
|
|
2094
|
+
const existingSession = sessionsRef.current.get(sessionKey);
|
|
2095
|
+
if (existingSession && existingSession.threadId === threadId) {
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
if (existingSession?.threadId) {
|
|
2099
|
+
const previousDedupKey = makeActiveCellAttachmentDedupKey(sessionKey, existingSession.threadId);
|
|
2100
|
+
lastActiveCellAttachmentSignatureRef.current.delete(previousDedupKey);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
updateSessions(prev => {
|
|
2104
|
+
const next = new Map(prev);
|
|
2105
|
+
next.set(sessionKey, resetSession);
|
|
2106
|
+
return next;
|
|
2107
|
+
});
|
|
2108
|
+
activeSessionKeyByPathRef.current.set(notebookPath, sessionKey);
|
|
2109
|
+
clearRunMappingForSessionKey(sessionKey);
|
|
2110
|
+
|
|
2111
|
+
if (currentPath === notebookPath && currentNotebookSessionKeyRef.current !== sessionKey) {
|
|
2112
|
+
setCurrentNotebookSessionKey(sessionKey);
|
|
2113
|
+
}
|
|
2114
|
+
if (currentPath === notebookPath) {
|
|
2115
|
+
clearInputForCurrentSession();
|
|
2116
|
+
clearPendingImages();
|
|
2117
|
+
}
|
|
2118
|
+
sendStartSession(resetSession, notebookPath, sessionKey);
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
window.addEventListener('storage', onStorage);
|
|
2122
|
+
return () => {
|
|
2123
|
+
window.removeEventListener('storage', onStorage);
|
|
2124
|
+
};
|
|
2125
|
+
}, []);
|
|
2126
|
+
|
|
2127
|
+
function onSocketOpen(): void {
|
|
2128
|
+
resetSocketMessageQueue();
|
|
2129
|
+
if (hasDeleteAllPending()) {
|
|
2130
|
+
deleteAllSessionsOnServer();
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
const activeWidget = getActiveDocumentWidget(props.app, activeDocumentWidgetRef.current);
|
|
2134
|
+
if (activeWidget) {
|
|
2135
|
+
activeDocumentWidgetRef.current = activeWidget;
|
|
2136
|
+
}
|
|
2137
|
+
const notebookPath = currentNotebookPathRef.current || getSupportedDocumentPath(activeWidget);
|
|
2138
|
+
if (!notebookPath) {
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
const currentSessionKey = resolveCurrentSessionKey(notebookPath);
|
|
2143
|
+
const session = ensureSession(notebookPath, currentSessionKey);
|
|
2144
|
+
sendStartSession(session, notebookPath, currentSessionKey);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function onSocketClose(): void {
|
|
2148
|
+
resetSocketMessageQueue();
|
|
2149
|
+
runToSessionKeyRef.current = new Map();
|
|
2150
|
+
setIsPlainPyRunInProgress(false);
|
|
2151
|
+
plainPyRunSessionKeyRef.current = '';
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function processSocketMessage(rawMessage: unknown): void {
|
|
2155
|
+
try {
|
|
2156
|
+
handleCodexSocketMessage(rawMessage, {
|
|
2157
|
+
appendMessage,
|
|
2158
|
+
clearDeleteAllPending,
|
|
2159
|
+
coerceModelCatalog: parseModelCatalog,
|
|
2160
|
+
coerceReasoningEffort,
|
|
2161
|
+
coerceRateLimitsSnapshot,
|
|
2162
|
+
coerceSessionHistory,
|
|
2163
|
+
coerceNotebookMode,
|
|
2164
|
+
createSession,
|
|
2165
|
+
deleteAllSessionsOnServer,
|
|
2166
|
+
getCommandPath: () => commandPath,
|
|
2167
|
+
getCurrentSessionKey: () => currentNotebookSessionKeyRef.current,
|
|
2168
|
+
getStoredSelectionPreviews: () => storedSelectionPreviewsRef.current,
|
|
2169
|
+
hashSelectionPreviewContent,
|
|
2170
|
+
hasDeleteAllPending,
|
|
2171
|
+
isSessionStartNotice,
|
|
2172
|
+
markDeleteAllPending,
|
|
2173
|
+
normalizeSystemText,
|
|
2174
|
+
notifyRunDone,
|
|
2175
|
+
refreshNotebook,
|
|
2176
|
+
resolveMessageSessionKey,
|
|
2177
|
+
runToSessionKeyRef,
|
|
2178
|
+
setCliDefaults,
|
|
2179
|
+
setCommandPath,
|
|
2180
|
+
setRateLimits,
|
|
2181
|
+
setSessionConversationMode,
|
|
2182
|
+
setSessionPairing,
|
|
2183
|
+
setSessionProgress,
|
|
2184
|
+
setSessionRunState,
|
|
2185
|
+
getSessionThreadId: (sessionKey: string) => {
|
|
2186
|
+
const normalizedSessionKey = (sessionKey || '').trim();
|
|
2187
|
+
if (!normalizedSessionKey) {
|
|
2188
|
+
return '';
|
|
2189
|
+
}
|
|
2190
|
+
const session = sessionsRef.current.get(normalizedSessionKey);
|
|
2191
|
+
return (session?.threadId || '').trim();
|
|
2192
|
+
},
|
|
2193
|
+
appendActivityItem,
|
|
2194
|
+
syncEffectiveSandboxFromStatus,
|
|
2195
|
+
updateSessions: updater =>
|
|
2196
|
+
updateSessions(
|
|
2197
|
+
previous => updater(previous as Map<string, Record<string, unknown>>) as Map<string, NotebookSession>
|
|
2198
|
+
)
|
|
2199
|
+
});
|
|
2200
|
+
} catch (err) {
|
|
2201
|
+
const sessionKey = currentNotebookSessionKeyRef.current || '';
|
|
2202
|
+
if (sessionKey) {
|
|
2203
|
+
appendMessage(sessionKey, 'system', `Internal UI error while processing a server message: ${String(err)}`);
|
|
2204
|
+
}
|
|
2205
|
+
console.error('[Codex] onSocketMessage failed', err, rawMessage);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
function clearSocketMessageFlushTimer(): void {
|
|
2210
|
+
const rafId = socketMessageFlushRafRef.current;
|
|
2211
|
+
if (rafId !== null) {
|
|
2212
|
+
window.cancelAnimationFrame(rafId);
|
|
2213
|
+
socketMessageFlushRafRef.current = null;
|
|
2214
|
+
}
|
|
2215
|
+
const timerId = socketMessageFlushTimerRef.current;
|
|
2216
|
+
if (timerId === null) {
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
window.clearTimeout(timerId);
|
|
2220
|
+
socketMessageFlushTimerRef.current = null;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function flushSocketMessageQueue(): void {
|
|
2224
|
+
clearSocketMessageFlushTimer();
|
|
2225
|
+
const queue = socketMessageQueueRef.current;
|
|
2226
|
+
if (queue.length === 0) {
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
const batch = queue.splice(0, SOCKET_MESSAGE_BATCH_SIZE);
|
|
2231
|
+
for (const rawMessage of batch) {
|
|
2232
|
+
processSocketMessage(rawMessage);
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
if (queue.length > 0) {
|
|
2236
|
+
scheduleSocketMessageFlush();
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function scheduleSocketMessageFlush(): void {
|
|
2241
|
+
if (socketMessageFlushTimerRef.current !== null || socketMessageFlushRafRef.current !== null) {
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
if (typeof window.requestAnimationFrame === 'function') {
|
|
2245
|
+
socketMessageFlushRafRef.current = window.requestAnimationFrame(() => {
|
|
2246
|
+
socketMessageFlushRafRef.current = null;
|
|
2247
|
+
flushSocketMessageQueue();
|
|
2248
|
+
});
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
socketMessageFlushTimerRef.current = window.setTimeout(flushSocketMessageQueue, SOCKET_MESSAGE_FALLBACK_FLUSH_MS);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function resetSocketMessageQueue(): void {
|
|
2255
|
+
socketMessageQueueRef.current = [];
|
|
2256
|
+
clearSocketMessageFlushTimer();
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function onSocketMessage(rawMessage: unknown): void {
|
|
2260
|
+
const queue = socketMessageQueueRef.current;
|
|
2261
|
+
queue.push(rawMessage);
|
|
2262
|
+
if (queue.length > SOCKET_MESSAGE_MAX_QUEUE) {
|
|
2263
|
+
queue.splice(0, queue.length - SOCKET_MESSAGE_MAX_QUEUE);
|
|
2264
|
+
}
|
|
2265
|
+
scheduleSocketMessageFlush();
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
useEffect(() => {
|
|
2269
|
+
return () => {
|
|
2270
|
+
resetSocketMessageQueue();
|
|
2271
|
+
};
|
|
2272
|
+
}, []);
|
|
2273
|
+
|
|
2274
|
+
useEffect(() => {
|
|
2275
|
+
if (!isAtBottom) {
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
const id = window.requestAnimationFrame(() => scrollToBottom());
|
|
2279
|
+
return () => window.cancelAnimationFrame(id);
|
|
2280
|
+
}, [isAtBottom, sessions, currentNotebookPath, socketConnected]);
|
|
2281
|
+
|
|
2282
|
+
function autosizeComposerTextarea(el?: HTMLTextAreaElement | null): void {
|
|
2283
|
+
const textarea = el ?? composerTextareaRef.current;
|
|
2284
|
+
if (!textarea) {
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// Reset first so it can shrink as content is removed.
|
|
2289
|
+
textarea.style.height = 'auto';
|
|
2290
|
+
|
|
2291
|
+
const cs = window.getComputedStyle(textarea);
|
|
2292
|
+
|
|
2293
|
+
// Prefer CSS-controlled min/max heights so the JS sizing stays consistent
|
|
2294
|
+
// with the visual design (and any future CSS tweaks).
|
|
2295
|
+
let minHeight = Number.parseFloat(cs.minHeight || '');
|
|
2296
|
+
let maxHeight = Number.parseFloat(cs.maxHeight || '');
|
|
2297
|
+
|
|
2298
|
+
if (!Number.isFinite(minHeight) || minHeight <= 0) {
|
|
2299
|
+
const fontSize = Number.parseFloat(cs.fontSize || '13');
|
|
2300
|
+
let lineHeight = Number.parseFloat(cs.lineHeight || '');
|
|
2301
|
+
if (!Number.isFinite(lineHeight)) {
|
|
2302
|
+
lineHeight = fontSize * 1.35;
|
|
2303
|
+
}
|
|
2304
|
+
minHeight = lineHeight;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
if (!Number.isFinite(maxHeight) || maxHeight <= 0) {
|
|
2308
|
+
const fontSize = Number.parseFloat(cs.fontSize || '13');
|
|
2309
|
+
let lineHeight = Number.parseFloat(cs.lineHeight || '');
|
|
2310
|
+
if (!Number.isFinite(lineHeight)) {
|
|
2311
|
+
lineHeight = fontSize * 1.35;
|
|
2312
|
+
}
|
|
2313
|
+
maxHeight = lineHeight * 3;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
const scrollHeight = textarea.scrollHeight;
|
|
2317
|
+
const isEmpty = textarea.value.length === 0;
|
|
2318
|
+
const unclampedHeight = isEmpty ? minHeight : scrollHeight;
|
|
2319
|
+
const nextHeight = Math.ceil(Math.min(Math.max(unclampedHeight, minHeight), maxHeight));
|
|
2320
|
+
|
|
2321
|
+
textarea.style.height = `${nextHeight}px`;
|
|
2322
|
+
textarea.style.overflowY = scrollHeight > maxHeight + 1 ? 'auto' : 'hidden';
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
useEffect(() => {
|
|
2326
|
+
// Defer to the next frame so layout reflects the latest value.
|
|
2327
|
+
const id = window.requestAnimationFrame(() => autosizeComposerTextarea());
|
|
2328
|
+
return () => window.cancelAnimationFrame(id);
|
|
2329
|
+
}, [input]);
|
|
2330
|
+
|
|
2331
|
+
useEffect(() => {
|
|
2332
|
+
const onResize = () => autosizeComposerTextarea();
|
|
2333
|
+
window.addEventListener('resize', onResize);
|
|
2334
|
+
return () => window.removeEventListener('resize', onResize);
|
|
2335
|
+
}, []);
|
|
2336
|
+
|
|
2337
|
+
async function startNewThread(): Promise<void> {
|
|
2338
|
+
const path = currentNotebookPathRef.current || '';
|
|
2339
|
+
const sessionKey = currentNotebookSessionKeyRef.current || '';
|
|
2340
|
+
if (!path) {
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
if (!sessionKey) {
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
const existing = sessionsRef.current.get(sessionKey);
|
|
2348
|
+
const hasConversation =
|
|
2349
|
+
existing?.messages.some(
|
|
2350
|
+
msg => msg.kind === 'text' && (msg.role === 'user' || msg.role === 'assistant')
|
|
2351
|
+
) ?? false;
|
|
2352
|
+
if (hasConversation) {
|
|
2353
|
+
const result = await showDialog({
|
|
2354
|
+
title: 'Start a new thread?',
|
|
2355
|
+
body:
|
|
2356
|
+
'Starting a new thread will reset the current conversation, and you will not be able to view the previous conversation in this panel.\nContinue?',
|
|
2357
|
+
buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'New thread' })]
|
|
2358
|
+
});
|
|
2359
|
+
if (!result.button.accept) {
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
const newSessionBase = createThreadResetSession(path, sessionKey, createSessionEventId());
|
|
2365
|
+
const newSession: NotebookSession = {
|
|
2366
|
+
...newSessionBase,
|
|
2367
|
+
selectedModelOption: modelOption,
|
|
2368
|
+
selectedReasoningEffort: reasoningEffort,
|
|
2369
|
+
selectedSandboxMode: sandboxMode,
|
|
2370
|
+
};
|
|
2371
|
+
if (existing?.threadId) {
|
|
2372
|
+
const previousDedupKey = makeActiveCellAttachmentDedupKey(sessionKey, existing.threadId);
|
|
2373
|
+
lastActiveCellAttachmentSignatureRef.current.delete(previousDedupKey);
|
|
2374
|
+
}
|
|
2375
|
+
updateSessions(prev => {
|
|
2376
|
+
const next = new Map(prev);
|
|
2377
|
+
next.set(sessionKey, newSession);
|
|
2378
|
+
return next;
|
|
2379
|
+
});
|
|
2380
|
+
clearRunMappingForSessionKey(sessionKey);
|
|
2381
|
+
clearInputForCurrentSession();
|
|
2382
|
+
clearPendingImages();
|
|
2383
|
+
sendStartSession(newSession, path, sessionKey, { forceNewThread: true });
|
|
2384
|
+
emitSessionThreadEvent(sessionKey, path, newSession.threadId);
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
function cancelRun(): void {
|
|
2388
|
+
const sessionKey = currentNotebookSessionKeyRef.current || '';
|
|
2389
|
+
if (!sessionKey) {
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
const session = sessionKey ? sessionsRef.current.get(sessionKey) : null;
|
|
2393
|
+
const runId = session?.activeRunId ?? null;
|
|
2394
|
+
|
|
2395
|
+
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
|
2396
|
+
appendMessage(sessionKey, 'system', 'Cancel failed: WebSocket is not connected.');
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
if (!runId) {
|
|
2400
|
+
appendMessage(sessionKey, 'system', 'Cancel not available yet (waiting for run id).');
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
setSessionProgress(sessionKey, 'Cancelling...');
|
|
2405
|
+
safeSocketSend(JSON.stringify(buildCancelMessage(runId)));
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
function clearPendingImages(): void {
|
|
2409
|
+
for (const image of pendingImagesRef.current) {
|
|
2410
|
+
URL.revokeObjectURL(image.previewUrl);
|
|
2411
|
+
}
|
|
2412
|
+
pendingImagesRef.current = [];
|
|
2413
|
+
setPendingImages([]);
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
function removePendingImage(id: string): void {
|
|
2417
|
+
setPendingImages(prev => {
|
|
2418
|
+
const removed = prev.find(image => image.id === id);
|
|
2419
|
+
if (removed) {
|
|
2420
|
+
URL.revokeObjectURL(removed.previewUrl);
|
|
2421
|
+
}
|
|
2422
|
+
const next = prev.filter(image => image.id !== id);
|
|
2423
|
+
pendingImagesRef.current = next;
|
|
2424
|
+
return next;
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
function onComposerPaste(event: React.ClipboardEvent<HTMLTextAreaElement>): void {
|
|
2429
|
+
const items = event.clipboardData?.items;
|
|
2430
|
+
if (!items || items.length === 0) {
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
const found: File[] = [];
|
|
2435
|
+
for (let idx = 0; idx < items.length; idx += 1) {
|
|
2436
|
+
const item = items[idx];
|
|
2437
|
+
if (!item || item.kind !== 'file' || !item.type.startsWith('image/')) {
|
|
2438
|
+
continue;
|
|
2439
|
+
}
|
|
2440
|
+
const file = item.getAsFile();
|
|
2441
|
+
if (file) {
|
|
2442
|
+
found.push(file);
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
if (found.length === 0) {
|
|
2447
|
+
return;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
const existingCount = pendingImagesRef.current.length;
|
|
2451
|
+
if (existingCount >= MAX_IMAGE_ATTACHMENTS) {
|
|
2452
|
+
appendMessage(
|
|
2453
|
+
currentNotebookSessionKeyRef.current || '',
|
|
2454
|
+
'system',
|
|
2455
|
+
`Too many images attached (max ${MAX_IMAGE_ATTACHMENTS}).`
|
|
2456
|
+
);
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
const remainingSlots = MAX_IMAGE_ATTACHMENTS - existingCount;
|
|
2461
|
+
const toAdd: PendingImageAttachment[] = [];
|
|
2462
|
+
let skippedLarge = 0;
|
|
2463
|
+
let skippedTotal = 0;
|
|
2464
|
+
let totalBytes = pendingImagesRef.current.reduce((sum, image) => sum + image.file.size, 0);
|
|
2465
|
+
for (const file of found.slice(0, remainingSlots)) {
|
|
2466
|
+
if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) {
|
|
2467
|
+
skippedLarge += 1;
|
|
2468
|
+
continue;
|
|
2469
|
+
}
|
|
2470
|
+
if (totalBytes + file.size > MAX_IMAGE_ATTACHMENT_TOTAL_BYTES) {
|
|
2471
|
+
skippedTotal += 1;
|
|
2472
|
+
continue;
|
|
2473
|
+
}
|
|
2474
|
+
toAdd.push({ id: crypto.randomUUID(), file, previewUrl: URL.createObjectURL(file) });
|
|
2475
|
+
totalBytes += file.size;
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
if (skippedLarge > 0) {
|
|
2479
|
+
appendMessage(
|
|
2480
|
+
currentNotebookSessionKeyRef.current || '',
|
|
2481
|
+
'system',
|
|
2482
|
+
`Skipped ${skippedLarge} image(s): each must be <= ${Math.round(MAX_IMAGE_ATTACHMENT_BYTES / (1024 * 1024))}MB.`
|
|
2483
|
+
);
|
|
2484
|
+
}
|
|
2485
|
+
if (skippedTotal > 0) {
|
|
2486
|
+
appendMessage(
|
|
2487
|
+
currentNotebookSessionKeyRef.current || '',
|
|
2488
|
+
'system',
|
|
2489
|
+
`Skipped ${skippedTotal} image(s): total attachments must be <= ${Math.round(MAX_IMAGE_ATTACHMENT_TOTAL_BYTES / (1024 * 1024))}MB.`
|
|
2490
|
+
);
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
if (toAdd.length === 0) {
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
setPendingImages(prev => {
|
|
2498
|
+
const next = [...prev, ...toAdd];
|
|
2499
|
+
pendingImagesRef.current = next;
|
|
2500
|
+
return next;
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
async function buildQueuedImagePayloads(attachments: PendingImageAttachment[]): Promise<{ name: string; dataUrl: string }[] | null> {
|
|
2505
|
+
if (attachments.length === 0) {
|
|
2506
|
+
return [];
|
|
2507
|
+
}
|
|
2508
|
+
try {
|
|
2509
|
+
const output: { name: string; dataUrl: string }[] = [];
|
|
2510
|
+
for (const image of attachments) {
|
|
2511
|
+
output.push({
|
|
2512
|
+
name: image.file.name || 'image',
|
|
2513
|
+
dataUrl: await blobToDataUrl(image.file)
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
2516
|
+
return output;
|
|
2517
|
+
} catch (err) {
|
|
2518
|
+
appendMessage(
|
|
2519
|
+
currentNotebookSessionKeyRef.current || '',
|
|
2520
|
+
'system',
|
|
2521
|
+
`Failed to prepare image attachment: ${String(err)}`
|
|
2522
|
+
);
|
|
2523
|
+
return null;
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
async function sendMessage(options?: {
|
|
2528
|
+
forcedText?: string;
|
|
2529
|
+
forcedNotebookPath?: string;
|
|
2530
|
+
forcedSessionKey?: string;
|
|
2531
|
+
skipRunStateCheck?: boolean;
|
|
2532
|
+
}): Promise<boolean> {
|
|
2533
|
+
const socket = wsRef.current;
|
|
2534
|
+
const notebookPath = (options?.forcedNotebookPath ?? currentNotebookPathRef.current) || '';
|
|
2535
|
+
const sessionKey = (options?.forcedSessionKey ?? currentNotebookSessionKeyRef.current) || '';
|
|
2536
|
+
|
|
2537
|
+
if (!notebookPath) {
|
|
2538
|
+
return false;
|
|
2539
|
+
}
|
|
2540
|
+
if (!sessionKey) {
|
|
2541
|
+
return false;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
2545
|
+
appendMessage(sessionKey, 'system', 'WebSocket is not connected.');
|
|
2546
|
+
return false;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
const existing = sessionKey ? sessionsRef.current.get(sessionKey) : null;
|
|
2550
|
+
if (existing?.pairedOk === false) {
|
|
2551
|
+
appendMessage(
|
|
2552
|
+
sessionKey,
|
|
2553
|
+
'system',
|
|
2554
|
+
existing.pairedMessage ||
|
|
2555
|
+
`Jupytext paired file not found. Expected: ${existing.pairedOsPath || existing.pairedPath || '<notebook>.py'}`
|
|
2556
|
+
);
|
|
2557
|
+
return false;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
const forcedText = typeof options?.forcedText === 'string' ? options.forcedText : null;
|
|
2561
|
+
const trimmed = (forcedText ?? inputRef.current).trim();
|
|
2562
|
+
const hasImages = forcedText == null && pendingImagesRef.current.length > 0;
|
|
2563
|
+
if (!trimmed && !hasImages) {
|
|
2564
|
+
return false;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
const current = sessionKey ? sessionsRef.current.get(sessionKey) : null;
|
|
2568
|
+
if (current?.runState === 'running' && options?.skipRunStateCheck !== true) {
|
|
2569
|
+
return false;
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
const activeWidget = findDocumentWidgetByPath(props.app, notebookPath, activeDocumentWidgetRef.current);
|
|
2573
|
+
if (activeWidget) {
|
|
2574
|
+
activeDocumentWidgetRef.current = activeWidget;
|
|
2575
|
+
}
|
|
2576
|
+
const activeWidgetPath = getSupportedDocumentPath(activeWidget);
|
|
2577
|
+
const activeContext: any = activeWidget ? getDocumentContext(activeWidget) : null;
|
|
2578
|
+
if (
|
|
2579
|
+
autoSaveBeforeSend &&
|
|
2580
|
+
activeContext &&
|
|
2581
|
+
typeof activeContext.save === 'function' &&
|
|
2582
|
+
activeWidgetPath === notebookPath &&
|
|
2583
|
+
activeContext.model.dirty
|
|
2584
|
+
) {
|
|
2585
|
+
try {
|
|
2586
|
+
await activeContext.save();
|
|
2587
|
+
} catch (err) {
|
|
2588
|
+
appendMessage(sessionKey, 'system', `Auto-save failed: ${String(err)}`);
|
|
2589
|
+
return false;
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
const notebookMode = current?.notebookMode ?? inferNotebookModeFromPath(notebookPath);
|
|
2594
|
+
const session = ensureSession(notebookPath, sessionKey);
|
|
2595
|
+
const selectedContext = getSelectedContext(activeWidget, notebookMode);
|
|
2596
|
+
const selectedTextForContext = selectedContext?.text || '';
|
|
2597
|
+
let includeSelectionKey = false;
|
|
2598
|
+
let selection = '';
|
|
2599
|
+
if (includeActiveCell) {
|
|
2600
|
+
if (notebookMode === 'plain_py') {
|
|
2601
|
+
const selectedText =
|
|
2602
|
+
selectedTextForContext || getSelectedTextFromActiveCell(activeWidget) || getSelectedTextFromFileEditor(activeWidget);
|
|
2603
|
+
if (selectedText) {
|
|
2604
|
+
includeSelectionKey = true;
|
|
2605
|
+
selection = selectedText;
|
|
2606
|
+
}
|
|
2607
|
+
} else {
|
|
2608
|
+
includeSelectionKey = true;
|
|
2609
|
+
selection =
|
|
2610
|
+
selectedTextForContext || getActiveCellText(activeWidget) || getSelectedTextFromFileEditor(activeWidget);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
const includeCellOutputKey =
|
|
2614
|
+
includeActiveCell &&
|
|
2615
|
+
includeActiveCellOutput &&
|
|
2616
|
+
(notebookMode === 'ipynb' || notebookMode === 'jupytext_py');
|
|
2617
|
+
const cellOutputRaw = includeCellOutputKey ? getActiveCellOutput(activeWidget) : '';
|
|
2618
|
+
const attachmentLimit = limitActiveCellAttachmentPayload(
|
|
2619
|
+
includeSelectionKey ? selection : '',
|
|
2620
|
+
includeCellOutputKey ? cellOutputRaw : '',
|
|
2621
|
+
MAX_ACTIVE_CELL_SELECTION_CHARS,
|
|
2622
|
+
MAX_ACTIVE_CELL_OUTPUT_CHARS
|
|
2623
|
+
);
|
|
2624
|
+
const selectionForAttachment = includeSelectionKey ? attachmentLimit.selection : '';
|
|
2625
|
+
const cellOutputForAttachment = includeCellOutputKey ? attachmentLimit.cellOutput : '';
|
|
2626
|
+
const includeSelectionKeyAfterLimit = Boolean(selectionForAttachment);
|
|
2627
|
+
const includeCellOutputKeyAfterLimit = Boolean(cellOutputForAttachment);
|
|
2628
|
+
const messageSelectionPreview =
|
|
2629
|
+
includeSelectionKeyAfterLimit
|
|
2630
|
+
? toMessageSelectionPreview(selectedContext, activeWidget, notebookMode, selectionForAttachment)
|
|
2631
|
+
: undefined;
|
|
2632
|
+
const messageCellOutputPreview =
|
|
2633
|
+
includeCellOutputKeyAfterLimit
|
|
2634
|
+
? toCellOutputPreview(selectedContext, activeWidget, notebookMode, cellOutputForAttachment)
|
|
2635
|
+
: undefined;
|
|
2636
|
+
const shouldDeduplicateSelection = includeActiveCell && includeSelectionKeyAfterLimit;
|
|
2637
|
+
const shouldDeduplicateCellOutput =
|
|
2638
|
+
includeActiveCell && includeCellOutputKeyAfterLimit;
|
|
2639
|
+
const activeCellAttachmentDedupKey = makeActiveCellAttachmentDedupKey(sessionKey, session.threadId);
|
|
2640
|
+
const previousActiveCellSignatures =
|
|
2641
|
+
lastActiveCellAttachmentSignatureRef.current.get(activeCellAttachmentDedupKey);
|
|
2642
|
+
const activeCellSelectionSignature = shouldDeduplicateSelection
|
|
2643
|
+
? buildActiveCellSelectionSignature({
|
|
2644
|
+
notebookMode,
|
|
2645
|
+
text: selection,
|
|
2646
|
+
locationLabel: messageSelectionPreview?.locationLabel
|
|
2647
|
+
})
|
|
2648
|
+
: '';
|
|
2649
|
+
const activeCellOutputSignature = shouldDeduplicateCellOutput
|
|
2650
|
+
? buildActiveCellOutputSignature({
|
|
2651
|
+
notebookMode,
|
|
2652
|
+
text: cellOutputRaw,
|
|
2653
|
+
locationLabel: messageCellOutputPreview?.locationLabel
|
|
2654
|
+
})
|
|
2655
|
+
: '';
|
|
2656
|
+
const hasDuplicateSelectionAttachment =
|
|
2657
|
+
shouldDeduplicateSelection &&
|
|
2658
|
+
isDuplicateActiveCellAttachmentSignature(
|
|
2659
|
+
previousActiveCellSignatures?.selectionSignature,
|
|
2660
|
+
activeCellSelectionSignature
|
|
2661
|
+
);
|
|
2662
|
+
const hasDuplicateCellOutputAttachment =
|
|
2663
|
+
shouldDeduplicateCellOutput &&
|
|
2664
|
+
isDuplicateActiveCellAttachmentSignature(
|
|
2665
|
+
previousActiveCellSignatures?.cellOutputSignature,
|
|
2666
|
+
activeCellOutputSignature
|
|
2667
|
+
);
|
|
2668
|
+
const includeSelectionKeyForSend = includeSelectionKeyAfterLimit && !hasDuplicateSelectionAttachment;
|
|
2669
|
+
const includeCellOutputKeyForSend = includeCellOutputKeyAfterLimit && !hasDuplicateCellOutputAttachment;
|
|
2670
|
+
const sentAttachmentTruncation = resolveSentAttachmentTruncation({
|
|
2671
|
+
includeSelection: includeSelectionKeyForSend,
|
|
2672
|
+
includeCellOutput: includeCellOutputKeyForSend,
|
|
2673
|
+
selectionTruncated: attachmentLimit.selectionTruncated,
|
|
2674
|
+
cellOutputTruncated: attachmentLimit.cellOutputTruncated
|
|
2675
|
+
});
|
|
2676
|
+
const messageSelectionPreviewForSend = hasDuplicateSelectionAttachment ? undefined : messageSelectionPreview;
|
|
2677
|
+
const messageCellOutputPreviewForSend = hasDuplicateCellOutputAttachment ? undefined : messageCellOutputPreview;
|
|
2678
|
+
const messageContextPreview: MessageContextPreview | undefined =
|
|
2679
|
+
messageSelectionPreviewForSend || messageCellOutputPreviewForSend
|
|
2680
|
+
? {
|
|
2681
|
+
...(messageSelectionPreviewForSend ? { selectionPreview: messageSelectionPreviewForSend } : {}),
|
|
2682
|
+
...(messageCellOutputPreviewForSend ? { cellOutputPreview: messageCellOutputPreviewForSend } : {})
|
|
2683
|
+
}
|
|
2684
|
+
: undefined;
|
|
2685
|
+
const modelForSend = current?.selectedModelOption ?? modelOption;
|
|
2686
|
+
const reasoningForSend = current?.selectedReasoningEffort ?? reasoningEffort;
|
|
2687
|
+
const sandboxForSend = current?.selectedSandboxMode ?? sandboxMode;
|
|
2688
|
+
const selectedModelForSend =
|
|
2689
|
+
modelForSend === '__config__' ? (autoModel || '').trim() : modelForSend.trim();
|
|
2690
|
+
const selectedReasoningForSend =
|
|
2691
|
+
reasoningForSend === '__config__'
|
|
2692
|
+
? coerceReasoningEffort(autoReasoningEffort || '') || resolveFallbackReasoningEffort(reasoningOptions)
|
|
2693
|
+
: reasoningForSend;
|
|
2694
|
+
if (!selectedModelForSend) {
|
|
2695
|
+
appendMessage(
|
|
2696
|
+
sessionKey,
|
|
2697
|
+
'system',
|
|
2698
|
+
'Model is not resolved yet. Wait for model defaults to load, or pick a model explicitly.'
|
|
2699
|
+
);
|
|
2700
|
+
return false;
|
|
2701
|
+
}
|
|
2702
|
+
if (!selectedReasoningForSend) {
|
|
2703
|
+
appendMessage(
|
|
2704
|
+
sessionKey,
|
|
2705
|
+
'system',
|
|
2706
|
+
'Reasoning level is not resolved yet. Wait for defaults to load, or pick a reasoning level explicitly.'
|
|
2707
|
+
);
|
|
2708
|
+
return false;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
const content = trimmed || (hasImages ? 'Please analyze the attached image(s).' : '');
|
|
2712
|
+
const imagePayloads = hasImages ? await buildQueuedImagePayloads(pendingImagesRef.current) : null;
|
|
2713
|
+
if (hasImages && !imagePayloads) {
|
|
2714
|
+
return false;
|
|
2715
|
+
}
|
|
2716
|
+
let images: { name: string; dataUrl: string }[] | undefined;
|
|
2717
|
+
if (imagePayloads && imagePayloads.length > 0) {
|
|
2718
|
+
images = imagePayloads;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
if (
|
|
2722
|
+
!safeSocketSend(
|
|
2723
|
+
JSON.stringify(
|
|
2724
|
+
buildSendMessage({
|
|
2725
|
+
sessionId: session.threadId,
|
|
2726
|
+
sessionContextKey: sessionKey,
|
|
2727
|
+
content,
|
|
2728
|
+
notebookPath,
|
|
2729
|
+
commandPath: commandPath.trim(),
|
|
2730
|
+
model: selectedModelForSend,
|
|
2731
|
+
reasoningEffort: selectedReasoningForSend,
|
|
2732
|
+
sandbox: sandboxForSend,
|
|
2733
|
+
...(includeSelectionKeyForSend ? { selection: selectionForAttachment } : {}),
|
|
2734
|
+
...(includeCellOutputKeyForSend ? { cellOutput: cellOutputForAttachment } : {}),
|
|
2735
|
+
...(sentAttachmentTruncation.selectionTruncated ? { selectionTruncated: true } : {}),
|
|
2736
|
+
...(sentAttachmentTruncation.cellOutputTruncated ? { cellOutputTruncated: true } : {}),
|
|
2737
|
+
...(images ? { images } : {}),
|
|
2738
|
+
...(messageSelectionPreviewForSend ? { uiSelectionPreview: messageSelectionPreviewForSend } : {}),
|
|
2739
|
+
...(messageCellOutputPreviewForSend ? { uiCellOutputPreview: messageCellOutputPreviewForSend } : {})
|
|
2740
|
+
})
|
|
2741
|
+
)
|
|
2742
|
+
)
|
|
2743
|
+
) {
|
|
2744
|
+
appendMessage(sessionKey, 'system', 'Failed to send message to Codex: WebSocket is unavailable.');
|
|
2745
|
+
return false;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
if (shouldDeduplicateSelection || shouldDeduplicateCellOutput) {
|
|
2749
|
+
const nextActiveCellSignatures: ActiveCellAttachmentSignatures = { ...(previousActiveCellSignatures ?? {}) };
|
|
2750
|
+
if (shouldDeduplicateSelection && activeCellSelectionSignature) {
|
|
2751
|
+
nextActiveCellSignatures.selectionSignature = activeCellSelectionSignature;
|
|
2752
|
+
}
|
|
2753
|
+
if (shouldDeduplicateCellOutput && activeCellOutputSignature) {
|
|
2754
|
+
nextActiveCellSignatures.cellOutputSignature = activeCellOutputSignature;
|
|
2755
|
+
}
|
|
2756
|
+
if (nextActiveCellSignatures.selectionSignature || nextActiveCellSignatures.cellOutputSignature) {
|
|
2757
|
+
lastActiveCellAttachmentSignatureRef.current.set(activeCellAttachmentDedupKey, nextActiveCellSignatures);
|
|
2758
|
+
} else {
|
|
2759
|
+
lastActiveCellAttachmentSignatureRef.current.delete(activeCellAttachmentDedupKey);
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
appendStoredSelectionPreviewEntry(session.threadId, content, messageContextPreview);
|
|
2763
|
+
|
|
2764
|
+
const imageCount = images ? images.length : 0;
|
|
2765
|
+
const showReadOnlyWarning = sandboxForSend === 'read-only';
|
|
2766
|
+
const attachmentTruncationNotice = buildAttachmentTruncationNotice(
|
|
2767
|
+
sentAttachmentTruncation.selectionTruncated,
|
|
2768
|
+
sentAttachmentTruncation.cellOutputTruncated,
|
|
2769
|
+
MAX_ACTIVE_CELL_SELECTION_CHARS,
|
|
2770
|
+
MAX_ACTIVE_CELL_OUTPUT_CHARS
|
|
2771
|
+
);
|
|
2772
|
+
if (notebookMode === 'plain_py' || notebookMode === 'jupytext_py') {
|
|
2773
|
+
plainPyRunSessionKeyRef.current = sessionKey;
|
|
2774
|
+
setIsPlainPyRunInProgress(true);
|
|
2775
|
+
}
|
|
2776
|
+
updateSessions(prev => {
|
|
2777
|
+
const next = new Map(prev);
|
|
2778
|
+
const existing = next.get(sessionKey) ?? createSession('', `Session started`, { sessionKey });
|
|
2779
|
+
const warningEntry: ChatEntry[] = showReadOnlyWarning
|
|
2780
|
+
? [
|
|
2781
|
+
{
|
|
2782
|
+
kind: 'text',
|
|
2783
|
+
id: crypto.randomUUID(),
|
|
2784
|
+
role: 'system',
|
|
2785
|
+
text: normalizeSystemText('system', READ_ONLY_PERMISSION_WARNING)
|
|
2786
|
+
}
|
|
2787
|
+
]
|
|
2788
|
+
: [];
|
|
2789
|
+
const truncationNoticeEntry: ChatEntry[] = attachmentTruncationNotice
|
|
2790
|
+
? [
|
|
2791
|
+
{
|
|
2792
|
+
kind: 'text',
|
|
2793
|
+
id: crypto.randomUUID(),
|
|
2794
|
+
role: 'system',
|
|
2795
|
+
text: normalizeSystemText('system', attachmentTruncationNotice)
|
|
2796
|
+
}
|
|
2797
|
+
]
|
|
2798
|
+
: [];
|
|
2799
|
+
const updatedMessages: ChatEntry[] = [
|
|
2800
|
+
...existing.messages,
|
|
2801
|
+
...warningEntry,
|
|
2802
|
+
{
|
|
2803
|
+
kind: 'text',
|
|
2804
|
+
id: crypto.randomUUID(),
|
|
2805
|
+
role: 'user',
|
|
2806
|
+
text: content,
|
|
2807
|
+
attachments: imageCount > 0 ? { images: imageCount } : undefined,
|
|
2808
|
+
selectionPreview: messageSelectionPreviewForSend,
|
|
2809
|
+
cellOutputPreview: messageCellOutputPreviewForSend
|
|
2810
|
+
},
|
|
2811
|
+
...truncationNoticeEntry
|
|
2812
|
+
];
|
|
2813
|
+
next.set(sessionKey, {
|
|
2814
|
+
...existing,
|
|
2815
|
+
messages: trimSessionMessages(updatedMessages),
|
|
2816
|
+
runState: 'running',
|
|
2817
|
+
activeRunId: null,
|
|
2818
|
+
runStartedAt: Date.now(),
|
|
2819
|
+
progress: '',
|
|
2820
|
+
progressKind: ''
|
|
2821
|
+
});
|
|
2822
|
+
return next;
|
|
2823
|
+
});
|
|
2824
|
+
if (forcedText == null) {
|
|
2825
|
+
clearInputForCurrentSession();
|
|
2826
|
+
clearPendingImages();
|
|
2827
|
+
}
|
|
2828
|
+
return true;
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
const currentSession = currentNotebookSessionKey ? sessions.get(currentNotebookSessionKey) : null;
|
|
2832
|
+
const messages = currentSession?.messages ?? [];
|
|
2833
|
+
const progress = currentSession?.progress ?? '';
|
|
2834
|
+
const progressKind = currentSession?.progressKind ?? '';
|
|
2835
|
+
const status: PanelStatus = socketConnected ? currentSession?.runState ?? 'ready' : 'disconnected';
|
|
2836
|
+
|
|
2837
|
+
useEffect(() => {
|
|
2838
|
+
closeSelectionPopover();
|
|
2839
|
+
}, [currentNotebookSessionKey]);
|
|
2840
|
+
|
|
2841
|
+
useEffect(() => {
|
|
2842
|
+
if (!selectionPopover) {
|
|
2843
|
+
return;
|
|
2844
|
+
}
|
|
2845
|
+
const exists = messages.some(entry => entry.kind === 'text' && entry.id === selectionPopover.messageId);
|
|
2846
|
+
if (exists) {
|
|
2847
|
+
return;
|
|
2848
|
+
}
|
|
2849
|
+
closeSelectionPopover();
|
|
2850
|
+
}, [messages, selectionPopover]);
|
|
2851
|
+
|
|
2852
|
+
useEffect(() => {
|
|
2853
|
+
const previous = previousSessionThreadIdsRef.current;
|
|
2854
|
+
for (const [sessionKey, session] of sessions) {
|
|
2855
|
+
const nextThreadId = (session?.threadId || '').trim();
|
|
2856
|
+
const previousThreadId = (previous.get(sessionKey) || '').trim();
|
|
2857
|
+
if (previousThreadId && nextThreadId && previousThreadId !== nextThreadId) {
|
|
2858
|
+
migrateStoredSelectionPreviewEntries(previousThreadId, nextThreadId);
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
const nextBySessionKey = new Map<string, string>();
|
|
2863
|
+
for (const [sessionKey, session] of sessions) {
|
|
2864
|
+
const threadId = (session?.threadId || '').trim();
|
|
2865
|
+
if (!sessionKey || !threadId) {
|
|
2866
|
+
continue;
|
|
2867
|
+
}
|
|
2868
|
+
nextBySessionKey.set(sessionKey, threadId);
|
|
2869
|
+
}
|
|
2870
|
+
previousSessionThreadIdsRef.current = nextBySessionKey;
|
|
2871
|
+
}, [sessions]);
|
|
2872
|
+
|
|
2873
|
+
const displayPath = currentNotebookPath
|
|
2874
|
+
? currentNotebookPath.split('/').pop() || 'Untitled'
|
|
2875
|
+
: 'No notebook';
|
|
2876
|
+
const composerNotebookMode = currentSession?.notebookMode ?? inferNotebookModeFromPath(currentNotebookPath);
|
|
2877
|
+
const cellAttachmentState = resolveCellAttachmentState({
|
|
2878
|
+
includeActiveCell,
|
|
2879
|
+
includeActiveCellOutput,
|
|
2880
|
+
notebookMode: composerNotebookMode,
|
|
2881
|
+
isNotebookEditor: currentDocumentIsNotebookEditor,
|
|
2882
|
+
currentNotebookPath,
|
|
2883
|
+
pairedOk: currentSession?.pairedOk
|
|
2884
|
+
});
|
|
2885
|
+
const includeCellOutputForNextSend = cellAttachmentState.outputEnabled;
|
|
2886
|
+
const showCellAttachmentBadge = cellAttachmentState.showBadge;
|
|
2887
|
+
const cellAttachmentContentEnabled = cellAttachmentState.contentEnabled;
|
|
2888
|
+
const cellAttachmentOutputEnabled = cellAttachmentState.outputEnabled;
|
|
2889
|
+
|
|
2890
|
+
useEffect(() => {
|
|
2891
|
+
if (showCellAttachmentBadge) {
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
setCellAttachmentPopoverOpen(false);
|
|
2895
|
+
clearCellAttachmentPopoverCloseTimer();
|
|
2896
|
+
}, [showCellAttachmentBadge]);
|
|
2897
|
+
|
|
2898
|
+
useEffect(() => {
|
|
2899
|
+
return () => {
|
|
2900
|
+
clearCellAttachmentPopoverCloseTimer();
|
|
2901
|
+
};
|
|
2902
|
+
}, []);
|
|
2903
|
+
|
|
2904
|
+
useEffect(() => {
|
|
2905
|
+
return () => {
|
|
2906
|
+
clearContextPopoverCloseTimer();
|
|
2907
|
+
};
|
|
2908
|
+
}, []);
|
|
2909
|
+
|
|
2910
|
+
const trimmedInput = input.trim();
|
|
2911
|
+
const canSend =
|
|
2912
|
+
status !== 'disconnected' &&
|
|
2913
|
+
currentNotebookPath.length > 0 &&
|
|
2914
|
+
currentSession?.pairedOk !== false;
|
|
2915
|
+
const sendButtonMode: 'send' | 'stop' = status === 'running' ? 'stop' : 'send';
|
|
2916
|
+
const runningSummary = status === 'running' ? progress || 'Working...' : '';
|
|
2917
|
+
const activeModelOption = currentSession?.selectedModelOption ?? modelOption;
|
|
2918
|
+
const activeReasoningEffort = currentSession?.selectedReasoningEffort ?? reasoningEffort;
|
|
2919
|
+
const activeSandboxMode = currentSession?.selectedSandboxMode ?? sandboxMode;
|
|
2920
|
+
const autoModelLabel = autoModel
|
|
2921
|
+
? findModelLabel(autoModel, modelOptions)
|
|
2922
|
+
: 'Auto';
|
|
2923
|
+
const selectedModelLabel =
|
|
2924
|
+
activeModelOption === '__config__'
|
|
2925
|
+
? autoModelLabel
|
|
2926
|
+
: findModelLabel(activeModelOption, modelOptions);
|
|
2927
|
+
const autoReasoningLabel = autoReasoningEffort
|
|
2928
|
+
? findReasoningLabel(autoReasoningEffort, reasoningOptions)
|
|
2929
|
+
: 'Auto';
|
|
2930
|
+
const selectedReasoningLabel =
|
|
2931
|
+
activeReasoningEffort === '__config__'
|
|
2932
|
+
? autoReasoningLabel
|
|
2933
|
+
: findReasoningLabel(activeReasoningEffort, reasoningOptions);
|
|
2934
|
+
const selectedSandboxLabel = SANDBOX_OPTIONS.find(option => option.value === activeSandboxMode)?.label ?? 'Permission';
|
|
2935
|
+
const notificationPermission = getBrowserNotificationPermission();
|
|
2936
|
+
const notificationsUnsupported = notificationPermission === 'unsupported';
|
|
2937
|
+
const minimumNotifyDurationLabel =
|
|
2938
|
+
notifyOnDoneMinSeconds === 0
|
|
2939
|
+
? 'All completed runs'
|
|
2940
|
+
: `Runs taking at least ${notifyOnDoneMinSeconds} second${notifyOnDoneMinSeconds === 1 ? '' : 's'}`;
|
|
2941
|
+
const notificationHelpText =
|
|
2942
|
+
notificationPermission === 'unsupported'
|
|
2943
|
+
? 'Browser notifications are not available in this environment.'
|
|
2944
|
+
: notificationPermission === 'denied'
|
|
2945
|
+
? 'Notifications are blocked for this site. Allow them in browser settings.'
|
|
2946
|
+
: notificationPermission === 'default'
|
|
2947
|
+
? 'Permission will be requested when enabling this option.'
|
|
2948
|
+
: `Shows a browser notification for ${minimumNotifyDurationLabel}.`;
|
|
2949
|
+
const hasPythonRunInProgress = Array.from(sessions.entries()).some(([sessionKey, session]) => {
|
|
2950
|
+
if (session.runState !== 'running') {
|
|
2951
|
+
return false;
|
|
2952
|
+
}
|
|
2953
|
+
const mode = session.notebookMode ?? inferNotebookModeFromPath(parseSessionKey(sessionKey).path);
|
|
2954
|
+
return mode === 'plain_py' || mode === 'jupytext_py';
|
|
2955
|
+
});
|
|
2956
|
+
const hasPythonRunLockRef = Boolean(plainPyRunSessionKeyRef.current);
|
|
2957
|
+
const canStop = status === 'running' && Boolean(currentSession?.activeRunId);
|
|
2958
|
+
const canSendNow = canSend && (Boolean(trimmedInput) || pendingImages.length > 0);
|
|
2959
|
+
const isPythonNotebookModeForLock = composerNotebookMode === 'plain_py' || composerNotebookMode === 'jupytext_py';
|
|
2960
|
+
const plainPythonSessionActive =
|
|
2961
|
+
isPythonNotebookModeForLock && (hasPythonRunInProgress || hasPythonRunLockRef) && sendButtonMode === 'send';
|
|
2962
|
+
const sendButtonDisabled =
|
|
2963
|
+
sendButtonMode === 'send'
|
|
2964
|
+
? !canSendNow || plainPythonSessionActive
|
|
2965
|
+
: sendButtonMode === 'stop'
|
|
2966
|
+
? !canStop
|
|
2967
|
+
: false;
|
|
2968
|
+
const nowMs = Date.now();
|
|
2969
|
+
const rateUpdatedAtMs = safeParseDateMs(rateLimits?.updatedAt ?? null);
|
|
2970
|
+
const rateAgeMs = rateUpdatedAtMs == null ? null : nowMs - rateUpdatedAtMs;
|
|
2971
|
+
const usageIsStale = rateAgeMs == null ? true : rateAgeMs > 10 * 60 * 1000;
|
|
2972
|
+
const sessionLeftPercent = percentLeftFromUsed(rateLimits?.primary?.usedPercent ?? null);
|
|
2973
|
+
const weeklyLeftPercent = percentLeftFromUsed(rateLimits?.secondary?.usedPercent ?? null);
|
|
2974
|
+
const usageIsUnknown = rateUpdatedAtMs == null && sessionLeftPercent == null && weeklyLeftPercent == null;
|
|
2975
|
+
const sessionResetsIn = formatResetsIn(rateLimits?.primary?.resetsAt ?? null, nowMs);
|
|
2976
|
+
const weeklyResetsIn = formatResetsIn(rateLimits?.secondary?.resetsAt ?? null, nowMs);
|
|
2977
|
+
const usageIsOverdue =
|
|
2978
|
+
sessionResetsIn === 'Overdue' || weeklyResetsIn === 'Overdue' || (rateAgeMs != null && rateAgeMs > 60 * 60 * 1000);
|
|
2979
|
+
const batteryLevel = sessionLeftPercent == null ? null : sessionLeftPercent / 100;
|
|
2980
|
+
const usageUpdatedAgo = rateAgeMs == null ? 'Unknown' : `${formatDurationShort(rateAgeMs)} ago`;
|
|
2981
|
+
const sessionWindowMinutes = rateLimits?.primary?.windowMinutes ?? null;
|
|
2982
|
+
const weeklyWindowMinutes = rateLimits?.secondary?.windowMinutes ?? null;
|
|
2983
|
+
const sessionWindowLabel = sessionWindowMinutes == null ? '' : `Window: ${Math.round(sessionWindowMinutes / 60)}h`;
|
|
2984
|
+
const weeklyWindowLabel =
|
|
2985
|
+
weeklyWindowMinutes == null ? '' : `Window: ${Math.round(weeklyWindowMinutes / (60 * 24))}d`;
|
|
2986
|
+
const contextWindowTokens = rateLimits?.contextWindow?.windowTokens ?? null;
|
|
2987
|
+
const contextUsedTokens = rateLimits?.contextWindow?.usedTokens ?? null;
|
|
2988
|
+
const contextLeftTokens = rateLimits?.contextWindow?.leftTokens ?? null;
|
|
2989
|
+
const contextUsedPercent = rateLimits?.contextWindow?.usedPercent ?? null;
|
|
2990
|
+
const contextLevel =
|
|
2991
|
+
typeof contextUsedPercent === 'number' && Number.isFinite(contextUsedPercent)
|
|
2992
|
+
? clampNumber(contextUsedPercent / 100, 0, 1)
|
|
2993
|
+
: null;
|
|
2994
|
+
const contextUsedLabel = formatTokenCount(contextUsedTokens);
|
|
2995
|
+
const contextLeftLabel = formatTokenCount(contextLeftTokens);
|
|
2996
|
+
const contextWindowLabel = formatTokenCount(contextWindowTokens);
|
|
2997
|
+
const contextUsedPercentLabel =
|
|
2998
|
+
typeof contextUsedPercent === 'number' && Number.isFinite(contextUsedPercent)
|
|
2999
|
+
? `${Math.round(clampNumber(contextUsedPercent, 0, 100))}%`
|
|
3000
|
+
: 'Unknown';
|
|
3001
|
+
const hasContextUsageSnapshot = rateLimits?.contextWindow != null;
|
|
3002
|
+
|
|
3003
|
+
useEffect(() => {
|
|
3004
|
+
if (hasContextUsageSnapshot) {
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
setContextPopoverOpen(false);
|
|
3008
|
+
clearContextPopoverCloseTimer();
|
|
3009
|
+
}, [hasContextUsageSnapshot]);
|
|
3010
|
+
|
|
3011
|
+
useLayoutEffect(() => {
|
|
3012
|
+
const target = notebookLabelRef.current;
|
|
3013
|
+
if (!target) {
|
|
3014
|
+
setIsNotebookLabelTruncated(false);
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
const update = (): void => {
|
|
3019
|
+
const next = target.scrollWidth - target.clientWidth > 1;
|
|
3020
|
+
setIsNotebookLabelTruncated(prev => (prev === next ? prev : next));
|
|
3021
|
+
};
|
|
3022
|
+
|
|
3023
|
+
update();
|
|
3024
|
+
|
|
3025
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
3026
|
+
window.addEventListener('resize', update);
|
|
3027
|
+
return () => {
|
|
3028
|
+
window.removeEventListener('resize', update);
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
const observer = new ResizeObserver(() => {
|
|
3033
|
+
update();
|
|
3034
|
+
});
|
|
3035
|
+
observer.observe(target);
|
|
3036
|
+
if (target.parentElement) {
|
|
3037
|
+
observer.observe(target.parentElement);
|
|
3038
|
+
}
|
|
3039
|
+
window.addEventListener('resize', update);
|
|
3040
|
+
|
|
3041
|
+
return () => {
|
|
3042
|
+
observer.disconnect();
|
|
3043
|
+
window.removeEventListener('resize', update);
|
|
3044
|
+
};
|
|
3045
|
+
}, [currentNotebookPath]);
|
|
3046
|
+
|
|
3047
|
+
return (
|
|
3048
|
+
<div className="jp-CodexChat">
|
|
3049
|
+
<div className="jp-CodexChat-header">
|
|
3050
|
+
<div className="jp-CodexChat-header-top">
|
|
3051
|
+
<div className="jp-CodexChat-header-left">
|
|
3052
|
+
<StatusPill status={status} />
|
|
3053
|
+
<span className="jp-CodexChat-notebookWrap" data-full-name={isNotebookLabelTruncated ? displayPath : undefined}>
|
|
3054
|
+
<span className="jp-CodexChat-notebook" ref={notebookLabelRef} title={isNotebookLabelTruncated ? displayPath : undefined}>
|
|
3055
|
+
{displayPath}
|
|
3056
|
+
</span>
|
|
3057
|
+
</span>
|
|
3058
|
+
</div>
|
|
3059
|
+
<div className="jp-CodexChat-header-actions">
|
|
3060
|
+
<button
|
|
3061
|
+
type="button"
|
|
3062
|
+
onClick={() => void startNewThread()}
|
|
3063
|
+
className="jp-CodexHeaderBtn"
|
|
3064
|
+
disabled={!currentNotebookPath || status === 'running'}
|
|
3065
|
+
aria-label="New thread"
|
|
3066
|
+
title="New thread"
|
|
3067
|
+
>
|
|
3068
|
+
<PlusIcon width={16} height={16} />
|
|
3069
|
+
</button>
|
|
3070
|
+
<div
|
|
3071
|
+
className="jp-CodexMenuWrap"
|
|
3072
|
+
ref={usageMenuWrapRef}
|
|
3073
|
+
>
|
|
3074
|
+
<button
|
|
3075
|
+
type="button"
|
|
3076
|
+
className={`jp-CodexHeaderBtn jp-CodexHeaderBtn-icon jp-CodexUsageBtn${usagePopoverOpen ? ' is-active is-open' : ''}${usageIsStale ? ' is-stale' : ''}${usageIsOverdue ? ' is-overdue' : ''}`}
|
|
3077
|
+
ref={usageBtnRef}
|
|
3078
|
+
onClick={() => toggleUsagePopover()}
|
|
3079
|
+
aria-label={sessionLeftPercent == null ? 'Codex usage' : `Codex usage: ${sessionLeftPercent}% left`}
|
|
3080
|
+
aria-haspopup="dialog"
|
|
3081
|
+
aria-expanded={usagePopoverOpen}
|
|
3082
|
+
title={
|
|
3083
|
+
sessionLeftPercent == null
|
|
3084
|
+
? 'Codex usage: unknown'
|
|
3085
|
+
: `Codex usage: ${sessionLeftPercent}% left (resets in ${sessionResetsIn})`
|
|
3086
|
+
}
|
|
3087
|
+
>
|
|
3088
|
+
<BatteryIcon level={batteryLevel} width={16} height={16} />
|
|
3089
|
+
</button>
|
|
3090
|
+
</div>
|
|
3091
|
+
<PortalMenu
|
|
3092
|
+
open={usagePopoverOpen}
|
|
3093
|
+
anchorRef={usageBtnRef}
|
|
3094
|
+
popoverRef={usagePopoverRef}
|
|
3095
|
+
className="jp-CodexUsagePopover"
|
|
3096
|
+
ariaLabel="Codex usage"
|
|
3097
|
+
role="dialog"
|
|
3098
|
+
align="right"
|
|
3099
|
+
>
|
|
3100
|
+
{(usageIsOverdue || usageIsStale) && (
|
|
3101
|
+
<div
|
|
3102
|
+
className={`jp-CodexUsageNotice${usageIsOverdue ? ' is-overdue' : usageIsStale ? ' is-stale' : ''}`}
|
|
3103
|
+
>
|
|
3104
|
+
<div className="jp-CodexUsageNoticeTitle">
|
|
3105
|
+
{usageIsUnknown
|
|
3106
|
+
? 'Usage unavailable'
|
|
3107
|
+
: usageIsOverdue
|
|
3108
|
+
? 'Overdue usage snapshot'
|
|
3109
|
+
: 'Stale usage snapshot'}
|
|
3110
|
+
</div>
|
|
3111
|
+
<div className="jp-CodexUsageNoticeBody">
|
|
3112
|
+
{usageIsUnknown ? 'Run Codex once to fetch usage limits.' : 'Run Codex again to refresh these numbers.'}
|
|
3113
|
+
</div>
|
|
3114
|
+
</div>
|
|
3115
|
+
)}
|
|
3116
|
+
|
|
3117
|
+
<div className="jp-CodexUsageSection">
|
|
3118
|
+
<div className="jp-CodexUsageSectionTop">
|
|
3119
|
+
<div className="jp-CodexUsageSectionTitle">Session</div>
|
|
3120
|
+
<div className="jp-CodexUsageSectionReset">Resets in {sessionResetsIn}</div>
|
|
3121
|
+
</div>
|
|
3122
|
+
<div className="jp-CodexUsageBar">
|
|
3123
|
+
<div
|
|
3124
|
+
className={`jp-CodexUsageBarFill${usageIsStale ? ' is-stale' : ''}`}
|
|
3125
|
+
style={{ width: `${sessionLeftPercent ?? 0}%` }}
|
|
3126
|
+
/>
|
|
3127
|
+
</div>
|
|
3128
|
+
<div className="jp-CodexUsageMeta">
|
|
3129
|
+
<div className="jp-CodexUsageMetaLeft">
|
|
3130
|
+
{sessionLeftPercent == null ? '--% left' : `${sessionLeftPercent}% left`}
|
|
3131
|
+
</div>
|
|
3132
|
+
<div className="jp-CodexUsageMetaRight">{sessionWindowLabel}</div>
|
|
3133
|
+
</div>
|
|
3134
|
+
</div>
|
|
3135
|
+
|
|
3136
|
+
<div className="jp-CodexMenuDivider" role="separator" />
|
|
3137
|
+
|
|
3138
|
+
<div className="jp-CodexUsageSection">
|
|
3139
|
+
<div className="jp-CodexUsageSectionTop">
|
|
3140
|
+
<div className="jp-CodexUsageSectionTitle">Weekly</div>
|
|
3141
|
+
<div className="jp-CodexUsageSectionReset">Resets in {weeklyResetsIn}</div>
|
|
3142
|
+
</div>
|
|
3143
|
+
<div className="jp-CodexUsageBar">
|
|
3144
|
+
<div
|
|
3145
|
+
className={`jp-CodexUsageBarFill${usageIsStale ? ' is-stale' : ''}`}
|
|
3146
|
+
style={{ width: `${weeklyLeftPercent ?? 0}%` }}
|
|
3147
|
+
/>
|
|
3148
|
+
</div>
|
|
3149
|
+
<div className="jp-CodexUsageMeta">
|
|
3150
|
+
<div className="jp-CodexUsageMetaLeft">
|
|
3151
|
+
{weeklyLeftPercent == null ? '--% left' : `${weeklyLeftPercent}% left`}
|
|
3152
|
+
</div>
|
|
3153
|
+
<div className="jp-CodexUsageMetaRight">{weeklyWindowLabel}</div>
|
|
3154
|
+
</div>
|
|
3155
|
+
</div>
|
|
3156
|
+
|
|
3157
|
+
<div className="jp-CodexUsageFooter">Last updated: {usageUpdatedAgo}</div>
|
|
3158
|
+
</PortalMenu>
|
|
3159
|
+
<button
|
|
3160
|
+
type="button"
|
|
3161
|
+
onClick={() => {
|
|
3162
|
+
setSettingsOpen(open => !open);
|
|
3163
|
+
setModelMenuOpen(false);
|
|
3164
|
+
setReasoningMenuOpen(false);
|
|
3165
|
+
setUsagePopoverOpen(false);
|
|
3166
|
+
setPermissionMenuOpen(false);
|
|
3167
|
+
setContextPopoverOpen(false);
|
|
3168
|
+
}}
|
|
3169
|
+
className={`jp-CodexHeaderBtn jp-CodexHeaderBtn-icon${settingsOpen ? ' is-active' : ''}`}
|
|
3170
|
+
aria-label="Settings"
|
|
3171
|
+
aria-expanded={settingsOpen}
|
|
3172
|
+
title="Settings"
|
|
3173
|
+
>
|
|
3174
|
+
<GearIcon width={16} height={16} />
|
|
3175
|
+
</button>
|
|
3176
|
+
</div>
|
|
3177
|
+
</div>
|
|
3178
|
+
|
|
3179
|
+
{currentSession?.pairedOk === false && (
|
|
3180
|
+
<div className="jp-CodexPairingNotice" role="status" aria-live="polite">
|
|
3181
|
+
<div className="jp-CodexPairingNotice-title">Jupytext pairing required</div>
|
|
3182
|
+
<div className="jp-CodexPairingNotice-body">
|
|
3183
|
+
{currentSession.pairedMessage ||
|
|
3184
|
+
'This notebook must be paired (.ipynb ↔ .py) via Jupytext to enable running.'}
|
|
3185
|
+
</div>
|
|
3186
|
+
</div>
|
|
3187
|
+
)}
|
|
3188
|
+
|
|
3189
|
+
</div>
|
|
3190
|
+
|
|
3191
|
+
<div className="jp-CodexChat-body">
|
|
3192
|
+
<div className="jp-CodexChat-messages" ref={scrollRef} onScroll={onScrollMessages}>
|
|
3193
|
+
{status === 'disconnected' && !isReconnecting && (
|
|
3194
|
+
<div className="jp-CodexChat-message jp-CodexChat-system jp-CodexChat-reconnectNotice">
|
|
3195
|
+
<div className="jp-CodexChat-role">system</div>
|
|
3196
|
+
<div className="jp-CodexChat-text">Codex connection was lost. Reconnect to continue.</div>
|
|
3197
|
+
<button
|
|
3198
|
+
type="button"
|
|
3199
|
+
className="jp-CodexReconnectBtn"
|
|
3200
|
+
onClick={() => reconnectSocket()}
|
|
3201
|
+
disabled={isReconnecting}
|
|
3202
|
+
aria-label={isReconnecting ? 'Codex reconnecting' : 'Reconnect to Codex'}
|
|
3203
|
+
title={isReconnecting ? 'Attempting to reconnect...' : 'Reconnect to Codex'}
|
|
3204
|
+
>
|
|
3205
|
+
{isReconnecting ? 'Connecting...' : 'Reconnect'}
|
|
3206
|
+
</button>
|
|
3207
|
+
</div>
|
|
3208
|
+
)}
|
|
3209
|
+
{messages.length === 0 && (
|
|
3210
|
+
<div className="jp-CodexChat-message jp-CodexChat-system">
|
|
3211
|
+
<div className="jp-CodexChat-role">system</div>
|
|
3212
|
+
<div className="jp-CodexChat-text">Select a notebook, then start a conversation.</div>
|
|
3213
|
+
</div>
|
|
3214
|
+
)}
|
|
3215
|
+
{messages.map(entry => {
|
|
3216
|
+
if (entry.kind === 'text') {
|
|
3217
|
+
const systemVariant =
|
|
3218
|
+
entry.role === 'system'
|
|
3219
|
+
? isSessionStartNotice(entry.text, entry.sessionResolution)
|
|
3220
|
+
? ' is-success'
|
|
3221
|
+
: ''
|
|
3222
|
+
: '';
|
|
3223
|
+
const imageCount = entry.attachments?.images ?? 0;
|
|
3224
|
+
const selectionPreview = entry.selectionPreview;
|
|
3225
|
+
const cellOutputPreview = entry.cellOutputPreview;
|
|
3226
|
+
const hasSelectionPreview =
|
|
3227
|
+
entry.role === 'user' &&
|
|
3228
|
+
Boolean(selectionPreview?.locationLabel && selectionPreview?.previewText);
|
|
3229
|
+
const hasCellOutputPreview =
|
|
3230
|
+
entry.role === 'user' &&
|
|
3231
|
+
Boolean(cellOutputPreview?.locationLabel && cellOutputPreview?.previewText);
|
|
3232
|
+
const hasContextPreview = hasSelectionPreview || hasCellOutputPreview;
|
|
3233
|
+
const isSelectionPreviewOpen = hasContextPreview && selectionPopover?.messageId === entry.id;
|
|
3234
|
+
const messageClassName = `jp-CodexChat-message jp-CodexChat-${entry.role}${systemVariant}${
|
|
3235
|
+
hasContextPreview ? ' has-selection-preview' : ''
|
|
3236
|
+
}${isSelectionPreviewOpen ? ' is-selection-open' : ''}`;
|
|
3237
|
+
return (
|
|
3238
|
+
<div
|
|
3239
|
+
key={entry.id}
|
|
3240
|
+
className={messageClassName}
|
|
3241
|
+
>
|
|
3242
|
+
<div className="jp-CodexChat-role">{entry.role}</div>
|
|
3243
|
+
<MessageText text={entry.text} canCopyCode={entry.role === 'assistant'} />
|
|
3244
|
+
{imageCount > 0 && (
|
|
3245
|
+
<div
|
|
3246
|
+
className="jp-CodexChat-attachments"
|
|
3247
|
+
aria-label={`${imageCount} image attachment(s)`}
|
|
3248
|
+
title={`${imageCount} image attachment(s)`}
|
|
3249
|
+
>
|
|
3250
|
+
<span className="jp-CodexChat-attachmentPill">
|
|
3251
|
+
<ImageIcon width={14} height={14} />
|
|
3252
|
+
<span className="jp-CodexChat-attachmentCount">{imageCount}</span>
|
|
3253
|
+
</span>
|
|
3254
|
+
</div>
|
|
3255
|
+
)}
|
|
3256
|
+
{hasContextPreview && (
|
|
3257
|
+
<button
|
|
3258
|
+
type="button"
|
|
3259
|
+
className={`jp-CodexChat-selectionToggle${isSelectionPreviewOpen ? ' is-open' : ''}`}
|
|
3260
|
+
onClick={event =>
|
|
3261
|
+
toggleSelectionPopover(
|
|
3262
|
+
entry.id,
|
|
3263
|
+
{
|
|
3264
|
+
...(selectionPreview ? { selectionPreview } : {}),
|
|
3265
|
+
...(cellOutputPreview ? { cellOutputPreview } : {})
|
|
3266
|
+
},
|
|
3267
|
+
event
|
|
3268
|
+
)
|
|
3269
|
+
}
|
|
3270
|
+
aria-label={isSelectionPreviewOpen ? 'Hide message context' : 'Show message context'}
|
|
3271
|
+
>
|
|
3272
|
+
<PlusIcon width="1em" height="1em" />
|
|
3273
|
+
</button>
|
|
3274
|
+
)}
|
|
3275
|
+
</div>
|
|
3276
|
+
);
|
|
3277
|
+
}
|
|
3278
|
+
if (entry.kind === 'run-divider') {
|
|
3279
|
+
return (
|
|
3280
|
+
<div key={entry.id} className="jp-CodexRunDivider" role="separator" aria-label="Run duration">
|
|
3281
|
+
<span className="jp-CodexRunDividerLabel">Worked for {formatRunDuration(entry.elapsedMs)}</span>
|
|
3282
|
+
</div>
|
|
3283
|
+
);
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3286
|
+
const item = entry.item;
|
|
3287
|
+
const trimmedDetail = (item.detail || '').trim();
|
|
3288
|
+
const isExpandable = Boolean(trimmedDetail);
|
|
3289
|
+
const activityClassName = `jp-CodexChat-message jp-CodexChat-activity${
|
|
3290
|
+
isExpandable ? ' is-expandable' : ''
|
|
3291
|
+
} is-${item.category}${item.phase ? ` is-${item.phase}` : ''}`;
|
|
3292
|
+
const icon =
|
|
3293
|
+
item.category === 'file' ? (
|
|
3294
|
+
<FileIcon width={14} height={14} />
|
|
3295
|
+
) : item.phase === 'completed' ? (
|
|
3296
|
+
<CheckIcon width={14} height={14} />
|
|
3297
|
+
) : item.phase === 'started' ? (
|
|
3298
|
+
<span className="jp-CodexActivityDot" />
|
|
3299
|
+
) : (
|
|
3300
|
+
<span className="jp-CodexActivityDot is-idle" />
|
|
3301
|
+
);
|
|
3302
|
+
|
|
3303
|
+
const summaryContent = (
|
|
3304
|
+
<>
|
|
3305
|
+
<span className="jp-CodexActivityLineIcon" aria-hidden="true">
|
|
3306
|
+
{icon}
|
|
3307
|
+
</span>
|
|
3308
|
+
<span className="jp-CodexActivityLineText">
|
|
3309
|
+
<span className="jp-CodexActivityLineTitle">{item.title}</span>
|
|
3310
|
+
</span>
|
|
3311
|
+
</>
|
|
3312
|
+
);
|
|
3313
|
+
|
|
3314
|
+
if (isExpandable) {
|
|
3315
|
+
return (
|
|
3316
|
+
<details
|
|
3317
|
+
key={entry.id}
|
|
3318
|
+
className={activityClassName}
|
|
3319
|
+
role="status"
|
|
3320
|
+
aria-live="polite"
|
|
3321
|
+
>
|
|
3322
|
+
<summary className="jp-CodexActivitySummary">{summaryContent}</summary>
|
|
3323
|
+
<div className="jp-CodexActivityBody">
|
|
3324
|
+
<pre className="jp-CodexActivityCode">
|
|
3325
|
+
<code>{trimmedDetail}</code>
|
|
3326
|
+
</pre>
|
|
3327
|
+
</div>
|
|
3328
|
+
</details>
|
|
3329
|
+
);
|
|
3330
|
+
}
|
|
3331
|
+
return (
|
|
3332
|
+
<div
|
|
3333
|
+
key={entry.id}
|
|
3334
|
+
className={activityClassName}
|
|
3335
|
+
role="status"
|
|
3336
|
+
aria-live="polite"
|
|
3337
|
+
>
|
|
3338
|
+
<div className="jp-CodexActivitySummary jp-CodexActivitySummaryStatic">
|
|
3339
|
+
{summaryContent}
|
|
3340
|
+
</div>
|
|
3341
|
+
</div>
|
|
3342
|
+
);
|
|
3343
|
+
})}
|
|
3344
|
+
|
|
3345
|
+
{status === 'running' && (
|
|
3346
|
+
<div
|
|
3347
|
+
className={`jp-CodexChat-loading${progressKind === 'reasoning' ? ' is-reasoning' : ''}`}
|
|
3348
|
+
aria-label={progressKind === 'reasoning' ? 'Reasoning' : 'Running'}
|
|
3349
|
+
>
|
|
3350
|
+
<div className="jp-CodexChat-loading-dots">
|
|
3351
|
+
<span></span>
|
|
3352
|
+
<span></span>
|
|
3353
|
+
<span></span>
|
|
3354
|
+
</div>
|
|
3355
|
+
</div>
|
|
3356
|
+
)}
|
|
3357
|
+
|
|
3358
|
+
<div ref={endRef} />
|
|
3359
|
+
</div>
|
|
3360
|
+
</div>
|
|
3361
|
+
<PortalMenu
|
|
3362
|
+
open={Boolean(selectionPopover)}
|
|
3363
|
+
anchorRef={selectionPopoverAnchorRef}
|
|
3364
|
+
popoverRef={selectionPopoverRef}
|
|
3365
|
+
className="jp-CodexChat-selectionPopover"
|
|
3366
|
+
ariaLabel="Message context"
|
|
3367
|
+
constrainHeightToViewport={true}
|
|
3368
|
+
viewportMargin={20}
|
|
3369
|
+
role="dialog"
|
|
3370
|
+
align="right"
|
|
3371
|
+
>
|
|
3372
|
+
{selectionPopover && (
|
|
3373
|
+
<div className="jp-CodexChat-selectionCard" role="note" aria-label="Message context">
|
|
3374
|
+
{selectionPopover.preview.selectionPreview && (
|
|
3375
|
+
<div className="jp-CodexChat-contextSection">
|
|
3376
|
+
<div className="jp-CodexChat-selectionMeta">{selectionPopover.preview.selectionPreview.locationLabel}</div>
|
|
3377
|
+
<SelectionPreviewCode code={selectionPopover.preview.selectionPreview.previewText} />
|
|
3378
|
+
</div>
|
|
3379
|
+
)}
|
|
3380
|
+
{selectionPopover.preview.cellOutputPreview && (
|
|
3381
|
+
<div className="jp-CodexChat-contextSection">
|
|
3382
|
+
<div className="jp-CodexChat-selectionMeta">{selectionPopover.preview.cellOutputPreview.locationLabel}</div>
|
|
3383
|
+
<SelectionPreviewCode code={selectionPopover.preview.cellOutputPreview.previewText} />
|
|
3384
|
+
</div>
|
|
3385
|
+
)}
|
|
3386
|
+
</div>
|
|
3387
|
+
)}
|
|
3388
|
+
</PortalMenu>
|
|
3389
|
+
|
|
3390
|
+
<PortalMenu
|
|
3391
|
+
open={cellAttachmentPopoverOpen && showCellAttachmentBadge}
|
|
3392
|
+
anchorRef={cellAttachmentAnchorRef as React.RefObject<HTMLElement>}
|
|
3393
|
+
popoverRef={cellAttachmentPopoverRef}
|
|
3394
|
+
className="jp-CodexCellAttachmentPopoverMenu"
|
|
3395
|
+
ariaLabel="Cell attachment details"
|
|
3396
|
+
role="dialog"
|
|
3397
|
+
align="left"
|
|
3398
|
+
onMouseEnter={openCellAttachmentPopover}
|
|
3399
|
+
onMouseLeave={scheduleCloseCellAttachmentPopover}
|
|
3400
|
+
>
|
|
3401
|
+
<div className="jp-CodexCellAttachmentPopoverCard" role="note" aria-label="Cell attachment details">
|
|
3402
|
+
<div className="jp-CodexCellAttachmentPopoverTitle">Attach On Next Send</div>
|
|
3403
|
+
<div className="jp-CodexCellAttachmentPopoverRow">
|
|
3404
|
+
<span>Current cell content</span>
|
|
3405
|
+
<span
|
|
3406
|
+
className={`jp-CodexCellAttachmentDot ${cellAttachmentContentEnabled ? 'is-on' : 'is-off'}`}
|
|
3407
|
+
aria-label={cellAttachmentContentEnabled ? 'Attached' : 'Not attached'}
|
|
3408
|
+
title={cellAttachmentContentEnabled ? 'Attached' : 'Not attached'}
|
|
3409
|
+
/>
|
|
3410
|
+
</div>
|
|
3411
|
+
<div className="jp-CodexCellAttachmentPopoverRow">
|
|
3412
|
+
<span>Current cell output</span>
|
|
3413
|
+
<span
|
|
3414
|
+
className={`jp-CodexCellAttachmentDot ${cellAttachmentOutputEnabled ? 'is-on' : 'is-off'}`}
|
|
3415
|
+
aria-label={cellAttachmentOutputEnabled ? 'Attached' : 'Not attached'}
|
|
3416
|
+
title={cellAttachmentOutputEnabled ? 'Attached' : 'Not attached'}
|
|
3417
|
+
/>
|
|
3418
|
+
</div>
|
|
3419
|
+
</div>
|
|
3420
|
+
</PortalMenu>
|
|
3421
|
+
|
|
3422
|
+
<div className="jp-CodexChat-input">
|
|
3423
|
+
<div className={`jp-CodexJumpBar${isAtBottom ? '' : ' is-visible'}`}>
|
|
3424
|
+
<button
|
|
3425
|
+
type="button"
|
|
3426
|
+
className="jp-CodexJumpToLatestBtn"
|
|
3427
|
+
onClick={scrollToBottom}
|
|
3428
|
+
aria-label="Jump to latest"
|
|
3429
|
+
aria-hidden={isAtBottom}
|
|
3430
|
+
tabIndex={isAtBottom ? -1 : 0}
|
|
3431
|
+
title="Jump to latest"
|
|
3432
|
+
>
|
|
3433
|
+
<ArrowDownIcon width={20} height={20} />
|
|
3434
|
+
</button>
|
|
3435
|
+
</div>
|
|
3436
|
+
<div className="jp-CodexComposer">
|
|
3437
|
+
<div
|
|
3438
|
+
className={`jp-CodexCellAttachmentWrap jp-CodexComposer-cellAttachmentWrap${showCellAttachmentBadge ? ' is-visible' : ''}`}
|
|
3439
|
+
ref={cellAttachmentAnchorRef}
|
|
3440
|
+
aria-hidden={!showCellAttachmentBadge}
|
|
3441
|
+
onMouseEnter={openCellAttachmentPopover}
|
|
3442
|
+
onMouseLeave={scheduleCloseCellAttachmentPopover}
|
|
3443
|
+
onFocusCapture={openCellAttachmentPopover}
|
|
3444
|
+
onBlurCapture={handleCellAttachmentBlur}
|
|
3445
|
+
>
|
|
3446
|
+
<div
|
|
3447
|
+
role="group"
|
|
3448
|
+
aria-label="Active-cell attachment"
|
|
3449
|
+
>
|
|
3450
|
+
<button
|
|
3451
|
+
type="button"
|
|
3452
|
+
className={`jp-CodexComposer-cellAttachment${cellAttachmentContentEnabled ? '' : ' is-off'}`}
|
|
3453
|
+
onClick={() => setIncludeActiveCell(value => !value)}
|
|
3454
|
+
aria-pressed={cellAttachmentContentEnabled}
|
|
3455
|
+
aria-label={cellAttachmentContentEnabled ? 'Disable active-cell attachment' : 'Enable active-cell attachment'}
|
|
3456
|
+
title={cellAttachmentContentEnabled ? 'Disable active-cell attachment' : 'Enable active-cell attachment'}
|
|
3457
|
+
disabled={!showCellAttachmentBadge}
|
|
3458
|
+
tabIndex={showCellAttachmentBadge ? 0 : -1}
|
|
3459
|
+
>
|
|
3460
|
+
<CellAttachmentIcon active={cellAttachmentContentEnabled} width={15} height={15} />
|
|
3461
|
+
<span className="jp-CodexComposer-cellAttachmentLabel">Cell Attatch</span>
|
|
3462
|
+
</button>
|
|
3463
|
+
</div>
|
|
3464
|
+
</div>
|
|
3465
|
+
<textarea
|
|
3466
|
+
ref={composerTextareaRef}
|
|
3467
|
+
value={input}
|
|
3468
|
+
onChange={e => {
|
|
3469
|
+
updateInput(e.currentTarget.value);
|
|
3470
|
+
// Resize using the current target so typing feels immediate.
|
|
3471
|
+
window.requestAnimationFrame(() => autosizeComposerTextarea(e.currentTarget));
|
|
3472
|
+
}}
|
|
3473
|
+
onPaste={onComposerPaste}
|
|
3474
|
+
placeholder={
|
|
3475
|
+
currentSession?.pairedOk === false
|
|
3476
|
+
? 'Disabled: missing Jupytext paired file (.py)'
|
|
3477
|
+
: currentNotebookPath
|
|
3478
|
+
? 'Ask Codex...'
|
|
3479
|
+
: 'Select a notebook first'
|
|
3480
|
+
}
|
|
3481
|
+
rows={1}
|
|
3482
|
+
onKeyDown={e => {
|
|
3483
|
+
// Avoid interfering with IME composition (Korean/Japanese/etc.)
|
|
3484
|
+
const native = e.nativeEvent as unknown as { isComposing?: boolean; keyCode?: number };
|
|
3485
|
+
if (native.isComposing || native.keyCode === 229) {
|
|
3486
|
+
return;
|
|
3487
|
+
}
|
|
3488
|
+
if (e.key !== 'Enter' || e.shiftKey) {
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
if (status === 'running') {
|
|
3492
|
+
e.preventDefault();
|
|
3493
|
+
return;
|
|
3494
|
+
}
|
|
3495
|
+
if (canSendNow) {
|
|
3496
|
+
e.preventDefault();
|
|
3497
|
+
void sendMessage();
|
|
3498
|
+
}
|
|
3499
|
+
}}
|
|
3500
|
+
/>
|
|
3501
|
+
{pendingImages.length > 0 && (
|
|
3502
|
+
<div className="jp-CodexComposer-attachments" role="group" aria-label="Attachments">
|
|
3503
|
+
{pendingImages.map(image => (
|
|
3504
|
+
<div key={image.id} className="jp-CodexComposer-attachment">
|
|
3505
|
+
<img src={image.previewUrl} alt={image.file.name || 'Pasted image'} />
|
|
3506
|
+
<button
|
|
3507
|
+
type="button"
|
|
3508
|
+
className="jp-CodexComposer-attachmentRemove"
|
|
3509
|
+
onClick={() => removePendingImage(image.id)}
|
|
3510
|
+
aria-label="Remove image"
|
|
3511
|
+
title="Remove image"
|
|
3512
|
+
>
|
|
3513
|
+
<XIcon width={14} height={14} />
|
|
3514
|
+
</button>
|
|
3515
|
+
</div>
|
|
3516
|
+
))}
|
|
3517
|
+
</div>
|
|
3518
|
+
)}
|
|
3519
|
+
<div className="jp-CodexComposer-toolbar">
|
|
3520
|
+
<div className="jp-CodexComposer-toolbarLeft">
|
|
3521
|
+
<div className="jp-CodexMenuWrap jp-CodexModelWrap" ref={modelMenuWrapRef}>
|
|
3522
|
+
<button
|
|
3523
|
+
type="button"
|
|
3524
|
+
ref={modelBtnRef}
|
|
3525
|
+
className={`jp-CodexModelBtn ${modelMenuOpen ? 'is-open' : ''}`}
|
|
3526
|
+
onClick={() => {
|
|
3527
|
+
setModelMenuOpen(open => !open);
|
|
3528
|
+
setReasoningMenuOpen(false);
|
|
3529
|
+
setUsagePopoverOpen(false);
|
|
3530
|
+
setPermissionMenuOpen(false);
|
|
3531
|
+
setContextPopoverOpen(false);
|
|
3532
|
+
}}
|
|
3533
|
+
disabled={status === 'running'}
|
|
3534
|
+
aria-label={`Model: ${selectedModelLabel}`}
|
|
3535
|
+
aria-haspopup="menu"
|
|
3536
|
+
aria-expanded={modelMenuOpen}
|
|
3537
|
+
title={`Model: ${selectedModelLabel}`}
|
|
3538
|
+
>
|
|
3539
|
+
<span className="jp-CodexModelBtn-label">{selectedModelLabel}</span>
|
|
3540
|
+
</button>
|
|
3541
|
+
</div>
|
|
3542
|
+
<PortalMenu
|
|
3543
|
+
open={modelMenuOpen}
|
|
3544
|
+
anchorRef={modelBtnRef}
|
|
3545
|
+
popoverRef={modelPopoverRef}
|
|
3546
|
+
role="menu"
|
|
3547
|
+
ariaLabel="Model"
|
|
3548
|
+
align="left"
|
|
3549
|
+
>
|
|
3550
|
+
{modelOptions.length === 0 && (
|
|
3551
|
+
<div className="jp-CodexMenuItem">No models available</div>
|
|
3552
|
+
)}
|
|
3553
|
+
{modelOptions.map(option => {
|
|
3554
|
+
const inferred =
|
|
3555
|
+
activeModelOption === '__config__' && autoModel && modelOptions.some(option => option.value === autoModel)
|
|
3556
|
+
? (autoModel as ModelOptionValue)
|
|
3557
|
+
: activeModelOption;
|
|
3558
|
+
const isActive = inferred === option.value;
|
|
3559
|
+
return (
|
|
3560
|
+
<button
|
|
3561
|
+
key={option.value}
|
|
3562
|
+
type="button"
|
|
3563
|
+
className={`jp-CodexMenuItem ${isActive ? 'is-active' : ''}`}
|
|
3564
|
+
onClick={() => {
|
|
3565
|
+
setCurrentSessionModelOption(option.value);
|
|
3566
|
+
setModelMenuOpen(false);
|
|
3567
|
+
}}
|
|
3568
|
+
>
|
|
3569
|
+
<span className="jp-CodexMenuItemLabel">{option.label}</span>
|
|
3570
|
+
{isActive && (
|
|
3571
|
+
<CheckIcon className="jp-CodexMenuCheck" width={16} height={16} />
|
|
3572
|
+
)}
|
|
3573
|
+
</button>
|
|
3574
|
+
);
|
|
3575
|
+
})}
|
|
3576
|
+
</PortalMenu>
|
|
3577
|
+
|
|
3578
|
+
<div className="jp-CodexMenuWrap" ref={reasoningMenuWrapRef}>
|
|
3579
|
+
<button
|
|
3580
|
+
type="button"
|
|
3581
|
+
ref={reasoningBtnRef}
|
|
3582
|
+
className={`jp-CodexIconBtn ${reasoningMenuOpen ? 'is-open' : ''}`}
|
|
3583
|
+
onClick={() => {
|
|
3584
|
+
setReasoningMenuOpen(open => !open);
|
|
3585
|
+
setModelMenuOpen(false);
|
|
3586
|
+
setUsagePopoverOpen(false);
|
|
3587
|
+
setPermissionMenuOpen(false);
|
|
3588
|
+
setContextPopoverOpen(false);
|
|
3589
|
+
}}
|
|
3590
|
+
disabled={status === 'running'}
|
|
3591
|
+
aria-label={`Reasoning: ${selectedReasoningLabel}`}
|
|
3592
|
+
aria-haspopup="menu"
|
|
3593
|
+
aria-expanded={reasoningMenuOpen}
|
|
3594
|
+
title={`Reasoning: ${selectedReasoningLabel}`}
|
|
3595
|
+
>
|
|
3596
|
+
<ReasoningEffortIcon
|
|
3597
|
+
isConfig={activeReasoningEffort === '__config__' && !autoReasoningEffort}
|
|
3598
|
+
activeBars={getReasoningEffortBars(
|
|
3599
|
+
(activeReasoningEffort === '__config__' && autoReasoningEffort
|
|
3600
|
+
? autoReasoningEffort
|
|
3601
|
+
: activeReasoningEffort) as ReasoningOptionValue,
|
|
3602
|
+
reasoningOptions
|
|
3603
|
+
)}
|
|
3604
|
+
width={17}
|
|
3605
|
+
height={17}
|
|
3606
|
+
/>
|
|
3607
|
+
</button>
|
|
3608
|
+
</div>
|
|
3609
|
+
<PortalMenu
|
|
3610
|
+
open={reasoningMenuOpen}
|
|
3611
|
+
anchorRef={reasoningBtnRef}
|
|
3612
|
+
popoverRef={reasoningPopoverRef}
|
|
3613
|
+
role="menu"
|
|
3614
|
+
ariaLabel="Reasoning"
|
|
3615
|
+
align="left"
|
|
3616
|
+
>
|
|
3617
|
+
{reasoningOptions.length === 0 && (
|
|
3618
|
+
<div className="jp-CodexMenuItem">No reasoning options</div>
|
|
3619
|
+
)}
|
|
3620
|
+
{reasoningOptions.map(option => {
|
|
3621
|
+
const inferred =
|
|
3622
|
+
activeReasoningEffort === '__config__' && autoReasoningEffort ? autoReasoningEffort : activeReasoningEffort;
|
|
3623
|
+
const isActive = inferred === option.value;
|
|
3624
|
+
return (
|
|
3625
|
+
<button
|
|
3626
|
+
key={option.value}
|
|
3627
|
+
type="button"
|
|
3628
|
+
className={`jp-CodexMenuItem ${isActive ? 'is-active' : ''}`}
|
|
3629
|
+
onClick={() => {
|
|
3630
|
+
setCurrentSessionReasoningEffort(option.value);
|
|
3631
|
+
setReasoningMenuOpen(false);
|
|
3632
|
+
}}
|
|
3633
|
+
>
|
|
3634
|
+
<span className="jp-CodexMenuItemLabel">{option.label}</span>
|
|
3635
|
+
{isActive && (
|
|
3636
|
+
<CheckIcon className="jp-CodexMenuCheck" width={16} height={16} />
|
|
3637
|
+
)}
|
|
3638
|
+
</button>
|
|
3639
|
+
);
|
|
3640
|
+
})}
|
|
3641
|
+
</PortalMenu>
|
|
3642
|
+
|
|
3643
|
+
<div className="jp-CodexMenuWrap" ref={permissionMenuWrapRef}>
|
|
3644
|
+
<button
|
|
3645
|
+
type="button"
|
|
3646
|
+
className={`jp-CodexIconBtn jp-CodexPermissionBtn${permissionMenuOpen ? ' is-open' : ''}${activeSandboxMode === 'danger-full-access' ? ' is-danger' : ''}${activeSandboxMode === 'read-only' ? ' is-warning' : ''}`}
|
|
3647
|
+
ref={permissionBtnRef}
|
|
3648
|
+
onClick={() => {
|
|
3649
|
+
setPermissionMenuOpen(open => !open);
|
|
3650
|
+
setModelMenuOpen(false);
|
|
3651
|
+
setReasoningMenuOpen(false);
|
|
3652
|
+
setUsagePopoverOpen(false);
|
|
3653
|
+
setContextPopoverOpen(false);
|
|
3654
|
+
}}
|
|
3655
|
+
disabled={status === 'running'}
|
|
3656
|
+
aria-label={`Permission: ${selectedSandboxLabel}`}
|
|
3657
|
+
aria-haspopup="menu"
|
|
3658
|
+
aria-expanded={permissionMenuOpen}
|
|
3659
|
+
title={`Permission: ${selectedSandboxLabel}`}
|
|
3660
|
+
>
|
|
3661
|
+
<ShieldIcon width={17} height={17} />
|
|
3662
|
+
</button>
|
|
3663
|
+
</div>
|
|
3664
|
+
<PortalMenu
|
|
3665
|
+
open={permissionMenuOpen}
|
|
3666
|
+
anchorRef={permissionBtnRef}
|
|
3667
|
+
popoverRef={permissionPopoverRef}
|
|
3668
|
+
role="menu"
|
|
3669
|
+
ariaLabel="Permissions"
|
|
3670
|
+
align="right"
|
|
3671
|
+
>
|
|
3672
|
+
{SANDBOX_OPTIONS.map(option => (
|
|
3673
|
+
<button
|
|
3674
|
+
key={option.value}
|
|
3675
|
+
type="button"
|
|
3676
|
+
className={`jp-CodexMenuItem ${activeSandboxMode === option.value ? 'is-active' : ''}`}
|
|
3677
|
+
onClick={() => {
|
|
3678
|
+
setCurrentSessionSandboxMode(option.value);
|
|
3679
|
+
setPermissionMenuOpen(false);
|
|
3680
|
+
}}
|
|
3681
|
+
>
|
|
3682
|
+
<span className="jp-CodexMenuItemLabel">{option.label}</span>
|
|
3683
|
+
{activeSandboxMode === option.value && <CheckIcon className="jp-CodexMenuCheck" width={16} height={16} />}
|
|
3684
|
+
</button>
|
|
3685
|
+
))}
|
|
3686
|
+
</PortalMenu>
|
|
3687
|
+
{hasContextUsageSnapshot && (
|
|
3688
|
+
<div
|
|
3689
|
+
className="jp-CodexContextWrap"
|
|
3690
|
+
ref={contextMenuWrapRef}
|
|
3691
|
+
onMouseEnter={openContextPopover}
|
|
3692
|
+
onMouseLeave={scheduleCloseContextPopover}
|
|
3693
|
+
onFocusCapture={openContextPopover}
|
|
3694
|
+
onBlurCapture={handleContextPopoverBlur}
|
|
3695
|
+
>
|
|
3696
|
+
<button
|
|
3697
|
+
type="button"
|
|
3698
|
+
className={`jp-CodexIconBtn jp-CodexContextBtn${usageIsStale ? ' is-stale' : ''}`}
|
|
3699
|
+
ref={contextBtnRef}
|
|
3700
|
+
aria-label={
|
|
3701
|
+
contextUsedTokens == null || contextLeftTokens == null
|
|
3702
|
+
? 'Context window usage unavailable'
|
|
3703
|
+
: `Context window: used ${contextUsedLabel} tokens, left ${contextLeftLabel} tokens`
|
|
3704
|
+
}
|
|
3705
|
+
title={
|
|
3706
|
+
contextUsedTokens == null || contextLeftTokens == null
|
|
3707
|
+
? 'Context window usage unavailable'
|
|
3708
|
+
: `Used ${contextUsedLabel} / left ${contextLeftLabel}`
|
|
3709
|
+
}
|
|
3710
|
+
>
|
|
3711
|
+
<ContextWindowIcon level={contextLevel} width={20} height={20} />
|
|
3712
|
+
</button>
|
|
3713
|
+
<PortalMenu
|
|
3714
|
+
open={contextPopoverOpen}
|
|
3715
|
+
anchorRef={contextBtnRef}
|
|
3716
|
+
popoverRef={contextPopoverRef}
|
|
3717
|
+
className="jp-CodexContextPopover"
|
|
3718
|
+
role="tooltip"
|
|
3719
|
+
ariaLabel="Context window"
|
|
3720
|
+
align="right"
|
|
3721
|
+
onMouseEnter={openContextPopover}
|
|
3722
|
+
onMouseLeave={scheduleCloseContextPopover}
|
|
3723
|
+
>
|
|
3724
|
+
<div className="jp-CodexContextPopoverTitle">Context window</div>
|
|
3725
|
+
<div className="jp-CodexContextPopoverRow">
|
|
3726
|
+
<span>Used</span>
|
|
3727
|
+
<strong>{contextUsedLabel}</strong>
|
|
3728
|
+
</div>
|
|
3729
|
+
<div className="jp-CodexContextPopoverRow">
|
|
3730
|
+
<span>Left</span>
|
|
3731
|
+
<strong>{contextLeftLabel}</strong>
|
|
3732
|
+
</div>
|
|
3733
|
+
<div className="jp-CodexContextPopoverMeta">
|
|
3734
|
+
{contextWindowTokens == null
|
|
3735
|
+
? 'Window size unavailable'
|
|
3736
|
+
: `Window: ${contextWindowLabel} tokens (${contextUsedPercentLabel} used)`}
|
|
3737
|
+
</div>
|
|
3738
|
+
</PortalMenu>
|
|
3739
|
+
</div>
|
|
3740
|
+
)}
|
|
3741
|
+
</div>
|
|
3742
|
+
|
|
3743
|
+
<div className="jp-CodexComposer-toolbarRight">
|
|
3744
|
+
<button
|
|
3745
|
+
type="button"
|
|
3746
|
+
className={`jp-CodexSendBtn${sendButtonMode === 'stop' ? ' is-stop' : ''}`}
|
|
3747
|
+
onClick={() => {
|
|
3748
|
+
if (sendButtonMode === 'stop') {
|
|
3749
|
+
cancelRun();
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
void sendMessage();
|
|
3753
|
+
}}
|
|
3754
|
+
disabled={sendButtonDisabled}
|
|
3755
|
+
aria-label={sendButtonMode === 'stop' ? 'Stop run' : 'Send'}
|
|
3756
|
+
title={
|
|
3757
|
+
sendButtonMode === 'stop'
|
|
3758
|
+
? currentSession?.activeRunId
|
|
3759
|
+
? `runId: ${currentSession.activeRunId}`
|
|
3760
|
+
: 'Waiting for run id...'
|
|
3761
|
+
: status === 'disconnected'
|
|
3762
|
+
? 'Connecting...'
|
|
3763
|
+
: 'Send'
|
|
3764
|
+
}
|
|
3765
|
+
>
|
|
3766
|
+
{sendButtonMode === 'stop' ? (
|
|
3767
|
+
<StopIcon width={18} height={18} />
|
|
3768
|
+
) : (
|
|
3769
|
+
<ArrowUpIcon width={18} height={18} />
|
|
3770
|
+
)}
|
|
3771
|
+
</button>
|
|
3772
|
+
</div>
|
|
3773
|
+
</div>
|
|
3774
|
+
</div>
|
|
3775
|
+
</div>
|
|
3776
|
+
|
|
3777
|
+
{settingsOpen && (
|
|
3778
|
+
<div className="jp-CodexSettingsOverlay" role="dialog" aria-modal="true" aria-label="Settings">
|
|
3779
|
+
<button
|
|
3780
|
+
type="button"
|
|
3781
|
+
className="jp-CodexSettingsBackdrop"
|
|
3782
|
+
onClick={() => setSettingsOpen(false)}
|
|
3783
|
+
aria-label="Dismiss settings"
|
|
3784
|
+
title="Dismiss settings"
|
|
3785
|
+
/>
|
|
3786
|
+
<div className="jp-CodexSettingsPanel" onClick={e => e.stopPropagation()}>
|
|
3787
|
+
<div className="jp-CodexSettingsPanel-top">
|
|
3788
|
+
<div className="jp-CodexSettingsPanel-title">Settings</div>
|
|
3789
|
+
<button
|
|
3790
|
+
type="button"
|
|
3791
|
+
className="jp-CodexHeaderBtn jp-CodexHeaderBtn-icon"
|
|
3792
|
+
onClick={() => setSettingsOpen(false)}
|
|
3793
|
+
aria-label="Close settings"
|
|
3794
|
+
title="Close"
|
|
3795
|
+
>
|
|
3796
|
+
<XIcon width={16} height={16} />
|
|
3797
|
+
</button>
|
|
3798
|
+
</div>
|
|
3799
|
+
<div className="jp-CodexSettingsPanel-sections">
|
|
3800
|
+
<section className="jp-CodexSettingsSection" aria-label="General settings">
|
|
3801
|
+
<div className="jp-CodexSettingsSection-title">General</div>
|
|
3802
|
+
<label className="jp-CodexSettingsField">
|
|
3803
|
+
<span className="jp-CodexSettingsField-label">Codex command path</span>
|
|
3804
|
+
<input
|
|
3805
|
+
type="text"
|
|
3806
|
+
className="jp-CodexChat-model-input"
|
|
3807
|
+
placeholder="codex"
|
|
3808
|
+
value={commandPath}
|
|
3809
|
+
disabled={status === 'running'}
|
|
3810
|
+
onChange={e => setCommandPath(e.currentTarget.value.trimStart())}
|
|
3811
|
+
title="Leave empty to use PATH lookup."
|
|
3812
|
+
/>
|
|
3813
|
+
<span className="jp-CodexSettingsField-help">Leave empty to use PATH lookup.</span>
|
|
3814
|
+
</label>
|
|
3815
|
+
</section>
|
|
3816
|
+
|
|
3817
|
+
<section className="jp-CodexSettingsSection" aria-label="Message options">
|
|
3818
|
+
<div className="jp-CodexSettingsSection-title">Message Options</div>
|
|
3819
|
+
<div className="jp-CodexSettingsOptions">
|
|
3820
|
+
<label className="jp-CodexChat-toggle">
|
|
3821
|
+
<input
|
|
3822
|
+
type="checkbox"
|
|
3823
|
+
checked={autoSaveBeforeSend}
|
|
3824
|
+
onChange={e => setAutoSaveBeforeSend(e.currentTarget.checked)}
|
|
3825
|
+
disabled={status === 'running'}
|
|
3826
|
+
/>
|
|
3827
|
+
Auto-save before send
|
|
3828
|
+
</label>
|
|
3829
|
+
<label className="jp-CodexChat-toggle">
|
|
3830
|
+
<input
|
|
3831
|
+
type="checkbox"
|
|
3832
|
+
checked={includeActiveCell}
|
|
3833
|
+
onChange={e => setIncludeActiveCell(e.currentTarget.checked)}
|
|
3834
|
+
disabled={status === 'running'}
|
|
3835
|
+
/>
|
|
3836
|
+
Include active cell
|
|
3837
|
+
</label>
|
|
3838
|
+
<label className="jp-CodexChat-toggle">
|
|
3839
|
+
<input
|
|
3840
|
+
type="checkbox"
|
|
3841
|
+
checked={includeActiveCellOutput}
|
|
3842
|
+
onChange={e => setIncludeActiveCellOutput(e.currentTarget.checked)}
|
|
3843
|
+
disabled={status === 'running' || !includeActiveCell}
|
|
3844
|
+
/>
|
|
3845
|
+
Include active cell output
|
|
3846
|
+
</label>
|
|
3847
|
+
</div>
|
|
3848
|
+
</section>
|
|
3849
|
+
|
|
3850
|
+
<section className="jp-CodexSettingsSection" aria-label="Notification options">
|
|
3851
|
+
<div className="jp-CodexSettingsSection-title">NOTIFICATION</div>
|
|
3852
|
+
<div className="jp-CodexSettingsOptions">
|
|
3853
|
+
<label className="jp-CodexChat-toggle">
|
|
3854
|
+
<input
|
|
3855
|
+
type="checkbox"
|
|
3856
|
+
checked={notifyOnDone}
|
|
3857
|
+
onChange={e => void updateNotifyOnDone(e.currentTarget.checked)}
|
|
3858
|
+
disabled={status === 'running' || notificationsUnsupported}
|
|
3859
|
+
/>
|
|
3860
|
+
Notify when run finishes
|
|
3861
|
+
</label>
|
|
3862
|
+
<label className="jp-CodexSettingsField">
|
|
3863
|
+
<span className="jp-CodexSettingsField-label">Minimum runtime (seconds)</span>
|
|
3864
|
+
<input
|
|
3865
|
+
type="number"
|
|
3866
|
+
min="0"
|
|
3867
|
+
step="1"
|
|
3868
|
+
className="jp-CodexChat-model-input"
|
|
3869
|
+
value={notifyOnDoneMinSeconds}
|
|
3870
|
+
onChange={e =>
|
|
3871
|
+
setNotifyOnDoneMinSeconds(
|
|
3872
|
+
Number.isFinite(Number(e.currentTarget.value)) ? Math.max(0, Math.floor(Number(e.currentTarget.value))) : 0
|
|
3873
|
+
)
|
|
3874
|
+
}
|
|
3875
|
+
disabled={notificationsUnsupported}
|
|
3876
|
+
/>
|
|
3877
|
+
<span className="jp-CodexSettingsField-help">
|
|
3878
|
+
0 means notify for every finished run. Enter only when you want delayed notifications.
|
|
3879
|
+
</span>
|
|
3880
|
+
</label>
|
|
3881
|
+
<span className="jp-CodexSettingsField-help">{notificationHelpText}</span>
|
|
3882
|
+
</div>
|
|
3883
|
+
</section>
|
|
3884
|
+
|
|
3885
|
+
<section className="jp-CodexSettingsSection jp-CodexSettingsSection-danger" aria-label="Danger zone">
|
|
3886
|
+
<div className="jp-CodexSettingsSection-title jp-CodexSettingsSection-title-danger">Danger Zone</div>
|
|
3887
|
+
<div className="jp-CodexSettingsPanel-stats">
|
|
3888
|
+
<span className="jp-CodexSettingsPanel-stat">
|
|
3889
|
+
Saved conversations: {storedThreadCount}
|
|
3890
|
+
</span>
|
|
3891
|
+
</div>
|
|
3892
|
+
<div className="jp-CodexSettingsDanger">
|
|
3893
|
+
<button
|
|
3894
|
+
type="button"
|
|
3895
|
+
className="jp-CodexBtn jp-CodexBtn-xs jp-CodexBtn-danger"
|
|
3896
|
+
onClick={() => void clearAllSessions()}
|
|
3897
|
+
disabled={status === 'running' || (storedThreadCount === 0 && sessions.size === 0)}
|
|
3898
|
+
title={
|
|
3899
|
+
status === 'running'
|
|
3900
|
+
? 'Cannot delete while a run is in progress.'
|
|
3901
|
+
: 'Delete all saved conversations'
|
|
3902
|
+
}
|
|
3903
|
+
>
|
|
3904
|
+
Delete all
|
|
3905
|
+
</button>
|
|
3906
|
+
</div>
|
|
3907
|
+
</section>
|
|
3908
|
+
</div>
|
|
3909
|
+
</div>
|
|
3910
|
+
</div>
|
|
3911
|
+
)}
|
|
3912
|
+
</div>
|
|
3913
|
+
);
|
|
3914
|
+
}
|