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.
Files changed (153) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.github/workflows/unit-tests.yml +27 -0
  3. package/.jupyterlab-playwright.log +0 -0
  4. package/README.md +83 -9
  5. package/docs/images/codex-sidebar-screenshot.png +0 -0
  6. package/jupyterlab_codex/handlers.py +938 -297
  7. package/jupyterlab_codex/labextension/package.json +13 -3
  8. package/jupyterlab_codex/labextension/static/525.224526d045c727069de6.js +2 -0
  9. package/jupyterlab_codex/labextension/static/737.e7de3ad9dd6ded798340.js +1 -0
  10. package/jupyterlab_codex/labextension/static/remoteEntry.6ef5e7167763a316c000.js +1 -0
  11. package/jupyterlab_codex/protocol.py +297 -0
  12. package/jupyterlab_codex/runner.py +58 -15
  13. package/jupyterlab_codex/sessions.py +582 -97
  14. package/lib/codexChat.d.ts +13 -0
  15. package/lib/codexChat.js +2506 -0
  16. package/lib/codexChat.js.map +1 -0
  17. package/lib/codexChatAttachmentDedup.d.ts +10 -0
  18. package/lib/codexChatAttachmentDedup.js +35 -0
  19. package/lib/codexChatAttachmentDedup.js.map +1 -0
  20. package/lib/codexChatAttachmentLimit.d.ts +18 -0
  21. package/lib/codexChatAttachmentLimit.js +50 -0
  22. package/lib/codexChatAttachmentLimit.js.map +1 -0
  23. package/lib/codexChatAttachmentState.d.ts +15 -0
  24. package/lib/codexChatAttachmentState.js +16 -0
  25. package/lib/codexChatAttachmentState.js.map +1 -0
  26. package/lib/codexChatDocumentUtils.d.ts +70 -0
  27. package/lib/codexChatDocumentUtils.js +506 -0
  28. package/lib/codexChatDocumentUtils.js.map +1 -0
  29. package/lib/codexChatFormatting.d.ts +11 -0
  30. package/lib/codexChatFormatting.js +83 -0
  31. package/lib/codexChatFormatting.js.map +1 -0
  32. package/lib/codexChatNotice.d.ts +3 -0
  33. package/lib/codexChatNotice.js +74 -0
  34. package/lib/codexChatNotice.js.map +1 -0
  35. package/lib/codexChatPersistence.d.ts +35 -0
  36. package/lib/codexChatPersistence.js +158 -0
  37. package/lib/codexChatPersistence.js.map +1 -0
  38. package/lib/codexChatPrimitives.d.ts +44 -0
  39. package/lib/codexChatPrimitives.js +156 -0
  40. package/lib/codexChatPrimitives.js.map +1 -0
  41. package/lib/codexChatRender.d.ts +24 -0
  42. package/lib/codexChatRender.js +293 -0
  43. package/lib/codexChatRender.js.map +1 -0
  44. package/lib/codexChatSessionFactory.d.ts +15 -0
  45. package/lib/codexChatSessionFactory.js +45 -0
  46. package/lib/codexChatSessionFactory.js.map +1 -0
  47. package/lib/codexChatSessionKey.d.ts +3 -0
  48. package/lib/codexChatSessionKey.js +14 -0
  49. package/lib/codexChatSessionKey.js.map +1 -0
  50. package/lib/codexChatStorage.d.ts +4 -0
  51. package/lib/codexChatStorage.js +37 -0
  52. package/lib/codexChatStorage.js.map +1 -0
  53. package/lib/codexSessionResolver.d.ts +12 -0
  54. package/lib/codexSessionResolver.js +38 -0
  55. package/lib/codexSessionResolver.js.map +1 -0
  56. package/lib/handlers/activitySummarizer.d.ts +15 -0
  57. package/lib/handlers/activitySummarizer.js +327 -0
  58. package/lib/handlers/activitySummarizer.js.map +1 -0
  59. package/lib/handlers/codexMessageTypes.d.ts +30 -0
  60. package/lib/handlers/codexMessageTypes.js +2 -0
  61. package/lib/handlers/codexMessageTypes.js.map +1 -0
  62. package/lib/handlers/codexMessageUtils.d.ts +46 -0
  63. package/lib/handlers/codexMessageUtils.js +144 -0
  64. package/lib/handlers/codexMessageUtils.js.map +1 -0
  65. package/lib/handlers/handleCodexSocketMessage.d.ts +107 -0
  66. package/lib/handlers/handleCodexSocketMessage.js +78 -0
  67. package/lib/handlers/handleCodexSocketMessage.js.map +1 -0
  68. package/lib/handlers/sessionSyncHandler.d.ts +34 -0
  69. package/lib/handlers/sessionSyncHandler.js +181 -0
  70. package/lib/handlers/sessionSyncHandler.js.map +1 -0
  71. package/lib/hooks/useCodexSocket.d.ts +15 -0
  72. package/lib/hooks/useCodexSocket.js +84 -0
  73. package/lib/hooks/useCodexSocket.js.map +1 -0
  74. package/lib/index.js +1 -1
  75. package/lib/index.js.map +1 -1
  76. package/lib/panel.d.ts +1 -11
  77. package/lib/panel.js +1 -2815
  78. package/lib/panel.js.map +1 -1
  79. package/lib/protocol.d.ts +235 -0
  80. package/lib/protocol.js +278 -0
  81. package/lib/protocol.js.map +1 -0
  82. package/package.json +13 -3
  83. package/playwright.config.cjs +27 -0
  84. package/playwright.unit.config.cjs +19 -0
  85. package/pyproject.toml +1 -1
  86. package/release.sh +52 -14
  87. package/scripts/run_playwright_e2e.sh +96 -0
  88. package/scripts/run_playwright_freeze_repro.sh +58 -0
  89. package/scripts/run_playwright_queue_repro.sh +60 -0
  90. package/scripts/run_playwright_repro.sh +55 -0
  91. package/src/codexChat.tsx +3914 -0
  92. package/src/codexChatAttachmentDedup.ts +47 -0
  93. package/src/codexChatAttachmentLimit.ts +81 -0
  94. package/src/codexChatAttachmentState.ts +37 -0
  95. package/src/codexChatDocumentUtils.ts +644 -0
  96. package/src/codexChatFormatting.ts +94 -0
  97. package/src/codexChatNotice.ts +95 -0
  98. package/src/codexChatPersistence.ts +191 -0
  99. package/src/codexChatPrimitives.tsx +446 -0
  100. package/src/codexChatRender.tsx +376 -0
  101. package/src/codexChatSessionFactory.ts +79 -0
  102. package/src/codexChatSessionKey.ts +16 -0
  103. package/src/codexChatStorage.ts +36 -0
  104. package/src/codexSessionResolver.ts +56 -0
  105. package/src/handlers/activitySummarizer.ts +369 -0
  106. package/src/handlers/codexMessageTypes.ts +34 -0
  107. package/src/handlers/codexMessageUtils.ts +217 -0
  108. package/src/handlers/handleCodexSocketMessage.ts +204 -0
  109. package/src/handlers/sessionSyncHandler.ts +308 -0
  110. package/src/hooks/useCodexSocket.ts +109 -0
  111. package/src/index.ts +1 -1
  112. package/src/panel.tsx +1 -4184
  113. package/src/protocol.ts +582 -0
  114. package/style/index.css +480 -11
  115. package/test-results/.last-run.json +4 -0
  116. package/test.py +0 -0
  117. package/tests/e2e/cell-output-error-tail.spec.js +156 -0
  118. package/tests/e2e/codex-ui-test-helpers.js +138 -0
  119. package/tests/e2e/fixtures/notebooks/error-output-tail.ipynb +58 -0
  120. package/tests/e2e/fixtures/notebooks/error-output-tail.py +19 -0
  121. package/tests/e2e/fixtures/notebooks/tab1.ipynb +322 -0
  122. package/tests/e2e/fixtures/notebooks/tab1.py +272 -0
  123. package/tests/e2e/fixtures/notebooks/tab2.ipynb +252 -0
  124. package/tests/e2e/fixtures/notebooks/tab2.py +231 -0
  125. package/tests/e2e/fixtures/notebooks/tab3.ipynb +403 -0
  126. package/tests/e2e/fixtures/notebooks/tab3.py +331 -0
  127. package/tests/e2e/fixtures/notebooks/tab4.py +339 -0
  128. package/tests/e2e/freeze-notebook-tabs-repro.spec.js +295 -0
  129. package/tests/e2e/mock-codex-cli-flood.py +127 -0
  130. package/tests/e2e/mock-codex-cli-prompt-echo.py +88 -0
  131. package/tests/e2e/mock-codex-cli.py +95 -0
  132. package/tests/e2e/queue-multitab-repro.spec.js +189 -0
  133. package/tests/test_handlers.py +116 -0
  134. package/tests/test_protocol.py +169 -0
  135. package/tests/test_session_store_limits.py +50 -0
  136. package/tests/unit/codexChatAttachmentDedup.spec.ts +56 -0
  137. package/tests/unit/codexChatAttachmentLimit.spec.ts +57 -0
  138. package/tests/unit/codexChatAttachmentState.spec.ts +71 -0
  139. package/tests/unit/codexChatDocumentUtils.spec.ts +63 -0
  140. package/tests/unit/codexChatLimit.spec.ts +18 -0
  141. package/tests/unit/codexChatNotice.spec.ts +45 -0
  142. package/tests/unit/codexChatPersistence.spec.ts +199 -0
  143. package/tests/unit/codexChatSessionFactory.spec.ts +94 -0
  144. package/tests/unit/codexChatSessionKey.spec.ts +18 -0
  145. package/tests/unit/codexMessageUtils.spec.ts +89 -0
  146. package/tests/unit/codexSessionResolver.spec.ts +92 -0
  147. package/tests/unit/handleCodexSocketMessage.spec.ts +476 -0
  148. package/tsconfig.tsbuildinfo +1 -1
  149. package/webpack.config.js +6 -0
  150. package/jupyterlab_codex/labextension/static/504.335f3447c84ba3d74517.js +0 -2
  151. package/jupyterlab_codex/labextension/static/972.8e856719e40acc1ef4cb.js +0 -1
  152. package/jupyterlab_codex/labextension/static/remoteEntry.a2982f776a1f0f515640.js +0 -1
  153. /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
+ }