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,2506 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { ReactWidget, Dialog, showDialog } from '@jupyterlab/apputils';
4
+ import { useCodexSocket } from './hooks/useCodexSocket';
5
+ import { handleCodexSocketMessage } from './handlers/handleCodexSocketMessage';
6
+ import { blobToDataUrl, MessageText, SelectionPreviewCode, StatusPill } from './codexChatRender';
7
+ import { parseModelCatalog, buildCancelMessage, buildDeleteAllSessionsMessage, buildSendMessage, buildStartSessionMessage } from './protocol';
8
+ import { coerceMessageContextPreview as coerceMessageContextPreviewShared, coerceSessionHistory as coerceSessionHistoryShared, coerceSelectionPreview as coerceSelectionPreviewShared, truncateEnd as truncateEndShared } from './handlers/codexMessageUtils';
9
+ import { clampNumber, coerceRateLimitsSnapshot, formatDurationShort, formatResetsIn, formatRunDuration, formatTokenCount, getBrowserNotificationPermission, percentLeftFromUsed, safeParseDateMs, truncateMiddle } from './codexChatFormatting';
10
+ import { isSessionStartNotice, normalizeSessionStartedNotice } from './codexChatNotice';
11
+ import { STORAGE_KEY_SESSION_THREADS, STORAGE_KEY_SESSION_THREADS_EVENT, buildSessionThreadSyncEvent, coerceSessionThreadSyncEvent, getStoredSessionThreadCount, writeSessionThreadSyncEvent, markDeleteAllPending, clearDeleteAllPending, hasDeleteAllPending, parseSessionKey, persistStoredSessionThreads, readStoredThreadId } from './codexChatPersistence';
12
+ import { createSession as createBaseSession, createThreadResetSession as createBaseThreadResetSession } from './codexChatSessionFactory';
13
+ import { resolveCurrentSessionKey, resolveSessionKey } from './codexChatSessionKey';
14
+ import { resolveMessageSessionKey as resolveMessageSessionKeyForMessage } from './codexSessionResolver';
15
+ import { MESSAGE_SELECTION_PREVIEW_STORED_MAX_CHARS, captureDocumentViewState, findDocumentWidgetByPath, getActiveCellOutput, getActiveCellText, getActiveDocumentWidget, getDocumentContext, getSelectedContext, getSelectedTextFromActiveCell, getSelectedTextFromFileEditor, getSupportedDocumentPath, isNotebookWidget, normalizeSelectionPreviewText, restoreDocumentViewState, toCellOutputPreview, toMessageSelectionPreview, } from './codexChatDocumentUtils';
16
+ import { resolveCellAttachmentState } from './codexChatAttachmentState';
17
+ import { buildActiveCellOutputSignature, buildActiveCellSelectionSignature, isDuplicateActiveCellAttachmentSignature, makeActiveCellAttachmentDedupKey } from './codexChatAttachmentDedup';
18
+ import { buildAttachmentTruncationNotice, limitActiveCellAttachmentPayload, resolveSentAttachmentTruncation } from './codexChatAttachmentLimit';
19
+ import { ArrowDownIcon, ArrowUpIcon, BatteryIcon, CellAttachmentIcon, CheckIcon, ContextWindowIcon, FileIcon, GearIcon, ImageIcon, PlusIcon, PortalMenu, ReasoningEffortIcon, ShieldIcon, StopIcon, XIcon } from './codexChatPrimitives';
20
+ import { hasStoredValue, safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from './codexChatStorage';
21
+ const truncateEnd = truncateEndShared;
22
+ export class CodexPanel extends ReactWidget {
23
+ constructor(app, notebooks) {
24
+ super();
25
+ this._app = app;
26
+ this._notebooks = notebooks;
27
+ this.addClass('jp-CodexPanel');
28
+ }
29
+ render() {
30
+ return _jsx(CodexChat, { app: this._app, notebooks: this._notebooks });
31
+ }
32
+ onAfterAttach(msg) {
33
+ super.onAfterAttach(msg);
34
+ this.focusComposer();
35
+ }
36
+ onActivateRequest(msg) {
37
+ super.onActivateRequest(msg);
38
+ this.focusComposer();
39
+ }
40
+ focusComposer() {
41
+ window.setTimeout(() => {
42
+ const textarea = this.node.querySelector('.jp-CodexComposer textarea');
43
+ if (!textarea || textarea.disabled) {
44
+ return;
45
+ }
46
+ textarea.focus({ preventScroll: true });
47
+ const cursor = textarea.value.length;
48
+ textarea.setSelectionRange(cursor, cursor);
49
+ }, 0);
50
+ }
51
+ }
52
+ function markLocalizedStartNotice(session) {
53
+ const firstEntry = session.messages[0];
54
+ if (firstEntry &&
55
+ firstEntry.kind === 'text' &&
56
+ firstEntry.role === 'system' &&
57
+ isSessionStartNotice(firstEntry.text)) {
58
+ firstEntry.sessionResolution = 'client';
59
+ }
60
+ return session;
61
+ }
62
+ const FALLBACK_MODEL_OPTIONS = [];
63
+ const DEFAULT_REASONING_EFFORT = 'medium';
64
+ const MAX_REASONING_EFFORT_BARS = 4;
65
+ const SANDBOX_OPTIONS = [
66
+ { label: 'Default permission', value: 'workspace-write' },
67
+ { label: 'Full access', value: 'danger-full-access' },
68
+ { label: 'Read only', value: 'read-only' }
69
+ ];
70
+ function readDefaultModelOption() {
71
+ const savedModel = readStoredModel();
72
+ if (savedModel && savedModel !== '__config__' && savedModel !== '__custom__') {
73
+ return savedModel;
74
+ }
75
+ return '__config__';
76
+ }
77
+ function readDefaultReasoningEffortOption() {
78
+ return readStoredReasoningEffort();
79
+ }
80
+ function readDefaultSandboxModeOption() {
81
+ return readStoredSandboxMode();
82
+ }
83
+ const AUTO_SAVE_STORAGE_KEY = 'jupyterlab-codex:auto-save-before-send';
84
+ const MODEL_STORAGE_KEY = 'jupyterlab-codex:model';
85
+ const COMMAND_PATH_STORAGE_KEY = 'jupyterlab-codex:command-path';
86
+ const REASONING_STORAGE_KEY = 'jupyterlab-codex:reasoning-effort';
87
+ const SANDBOX_MODE_STORAGE_KEY = 'jupyterlab-codex:sandbox-mode';
88
+ const SETTINGS_OPEN_STORAGE_KEY = 'jupyterlab-codex:settings-open';
89
+ const NOTIFY_ON_DONE_STORAGE_KEY = 'jupyterlab-codex:notify-on-done';
90
+ const NOTIFY_ON_DONE_MIN_SECONDS_STORAGE_KEY = 'jupyterlab-codex:notify-on-done-min-seconds';
91
+ const INCLUDE_ACTIVE_CELL_STORAGE_KEY = 'jupyterlab-codex:include-active-cell';
92
+ const INCLUDE_ACTIVE_CELL_OUTPUT_STORAGE_KEY = 'jupyterlab-codex:include-active-cell-output';
93
+ const SELECTION_PREVIEWS_STORAGE_KEY = 'jupyterlab-codex:selection-previews';
94
+ const MAX_IMAGE_ATTACHMENTS = 4;
95
+ const MAX_IMAGE_ATTACHMENT_BYTES = 4 * 1024 * 1024; // Avoid huge WebSocket payloads.
96
+ const MAX_IMAGE_ATTACHMENT_TOTAL_BYTES = 6 * 1024 * 1024;
97
+ const MAX_ACTIVE_CELL_SELECTION_CHARS = 4000;
98
+ const MAX_ACTIVE_CELL_OUTPUT_CHARS = 20000;
99
+ const MAX_STORED_SELECTION_PREVIEW_THREADS = 80;
100
+ const MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD = 10;
101
+ const MAX_SESSION_MESSAGES = 100;
102
+ const SOCKET_MESSAGE_BATCH_SIZE = 8;
103
+ const SOCKET_MESSAGE_MAX_QUEUE = 1200;
104
+ const SOCKET_MESSAGE_FALLBACK_FLUSH_MS = 16;
105
+ const READ_ONLY_PERMISSION_WARNING = 'Code changes are not available with the current permission (Read only).';
106
+ function findModelLabel(model, options) {
107
+ const match = options.find(option => option.value === model);
108
+ return match ? match.label : truncateMiddle(model, 32);
109
+ }
110
+ function coerceReasoningEffort(value) {
111
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
112
+ }
113
+ function coerceReasoningText(value) {
114
+ return typeof value === 'string' ? value.trim() : '';
115
+ }
116
+ function coerceReasoningEffortEntry(value) {
117
+ if (typeof value === 'string') {
118
+ return coerceReasoningText(value);
119
+ }
120
+ if (value && typeof value === 'object' && 'reasoningEffort' in value) {
121
+ return coerceReasoningText(value.reasoningEffort);
122
+ }
123
+ return '';
124
+ }
125
+ function readModelOptions(rawModels) {
126
+ const catalog = parseModelCatalog(rawModels);
127
+ return catalog.map(entry => ({ label: entry.displayName, value: entry.model }));
128
+ }
129
+ function getReasoningEffortBars(effort, effortOptions) {
130
+ if (!effort || effort === '__config__') {
131
+ return 0;
132
+ }
133
+ if (effortOptions.length <= 0) {
134
+ return 0;
135
+ }
136
+ const index = effortOptions.findIndex(option => option.value === effort);
137
+ if (index < 0) {
138
+ return 0;
139
+ }
140
+ if (effortOptions.length === 1) {
141
+ return 1;
142
+ }
143
+ const scale = MAX_REASONING_EFFORT_BARS - 1;
144
+ return Math.max(1, Math.min(MAX_REASONING_EFFORT_BARS, Math.floor((index * scale) / (effortOptions.length - 1)) + 1));
145
+ }
146
+ function buildReasoningOptions(rawModels, selectedModel) {
147
+ const catalog = parseModelCatalog(rawModels);
148
+ const normalizedModel = selectedModel.trim();
149
+ const modelByName = catalog.find(item => item.model === normalizedModel);
150
+ const reasons = modelByName?.reasoningEfforts && modelByName.reasoningEfforts.length > 0
151
+ ? modelByName.reasoningEfforts
152
+ : modelByName?.defaultReasoningEffort
153
+ ? [modelByName.defaultReasoningEffort]
154
+ : catalog.flatMap(item => item.reasoningEfforts ?? []);
155
+ const deduped = new Map();
156
+ for (const reason of reasons) {
157
+ const label = coerceReasoningEffortEntry(reason);
158
+ const normalized = coerceReasoningEffort(label);
159
+ if (!normalized || deduped.has(normalized)) {
160
+ continue;
161
+ }
162
+ deduped.set(normalized, { value: normalized, label: label || normalized });
163
+ }
164
+ return Array.from(deduped.values());
165
+ }
166
+ function resolveFallbackReasoningEffort(options) {
167
+ const medium = options.find(option => coerceReasoningEffort(option.value) === DEFAULT_REASONING_EFFORT);
168
+ if (medium) {
169
+ return medium.value;
170
+ }
171
+ return options[0]?.value ?? DEFAULT_REASONING_EFFORT;
172
+ }
173
+ function findReasoningLabel(value, options) {
174
+ const match = options.find(option => option.value === value);
175
+ if (match) {
176
+ return match.label;
177
+ }
178
+ const raw = coerceReasoningText(value);
179
+ if (!raw) {
180
+ return 'Reasoning';
181
+ }
182
+ return raw;
183
+ }
184
+ function isKnownSandboxMode(value) {
185
+ return SANDBOX_OPTIONS.some(option => option.value === value);
186
+ }
187
+ function coerceSandboxMode(value) {
188
+ if (typeof value !== 'string') {
189
+ return null;
190
+ }
191
+ const normalized = value.trim().toLowerCase().replace(/_/g, '-');
192
+ return isKnownSandboxMode(normalized) ? normalized : null;
193
+ }
194
+ function coerceNotebookMode(value) {
195
+ if (value === 'ipynb' || value === 'jupytext_py' || value === 'plain_py' || value === 'unsupported') {
196
+ return value;
197
+ }
198
+ return null;
199
+ }
200
+ function coerceConversationMode(value) {
201
+ if (value === 'resume' || value === 'fallback') {
202
+ return value;
203
+ }
204
+ return null;
205
+ }
206
+ function inferNotebookModeFromPath(path) {
207
+ const normalized = (path || '').trim().toLowerCase();
208
+ if (normalized.endsWith('.ipynb')) {
209
+ return 'ipynb';
210
+ }
211
+ if (normalized.endsWith('.py')) {
212
+ return 'plain_py';
213
+ }
214
+ return 'unsupported';
215
+ }
216
+ function createSessionEventId() {
217
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
218
+ return crypto.randomUUID();
219
+ }
220
+ return `${Date.now()}-${Math.floor(Math.random() * 0x100000000).toString(16)}`;
221
+ }
222
+ function hashSelectionPreviewContent(content) {
223
+ // Stable lightweight hash for local UI-only metadata matching.
224
+ const normalized = (content || '').replace(/\r\n/g, '\n');
225
+ let hash = 2166136261;
226
+ for (let idx = 0; idx < normalized.length; idx += 1) {
227
+ hash ^= normalized.charCodeAt(idx);
228
+ hash +=
229
+ (hash << 1) +
230
+ (hash << 4) +
231
+ (hash << 7) +
232
+ (hash << 8) +
233
+ (hash << 24);
234
+ }
235
+ return (hash >>> 0).toString(16).padStart(8, '0');
236
+ }
237
+ function normalizeStoredSelectionPreviewEntry(value) {
238
+ if (!value || typeof value !== 'object') {
239
+ return null;
240
+ }
241
+ const raw = value;
242
+ const contentHash = typeof raw.contentHash === 'string' ? raw.contentHash.trim() : '';
243
+ if (!contentHash) {
244
+ return null;
245
+ }
246
+ const previewRaw = raw.preview;
247
+ if (!previewRaw) {
248
+ return { contentHash, preview: null };
249
+ }
250
+ const preview = coerceMessageContextPreview(previewRaw);
251
+ if (!preview) {
252
+ return { contentHash, preview: null };
253
+ }
254
+ return {
255
+ contentHash,
256
+ preview
257
+ };
258
+ }
259
+ function readStoredSelectionPreviewsByThread() {
260
+ const raw = safeLocalStorageGet(SELECTION_PREVIEWS_STORAGE_KEY);
261
+ if (!raw) {
262
+ return new Map();
263
+ }
264
+ try {
265
+ const parsed = JSON.parse(raw);
266
+ if (!parsed || typeof parsed !== 'object') {
267
+ return new Map();
268
+ }
269
+ const next = new Map();
270
+ for (const [threadIdRaw, entriesRaw] of Object.entries(parsed)) {
271
+ const threadId = typeof threadIdRaw === 'string' ? threadIdRaw.trim() : '';
272
+ if (!threadId || !Array.isArray(entriesRaw)) {
273
+ continue;
274
+ }
275
+ const entries = [];
276
+ for (const entryCandidate of entriesRaw) {
277
+ const entry = normalizeStoredSelectionPreviewEntry(entryCandidate);
278
+ if (!entry) {
279
+ continue;
280
+ }
281
+ entries.push(entry);
282
+ }
283
+ if (entries.length <= 0) {
284
+ continue;
285
+ }
286
+ next.set(threadId, entries.slice(-MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD));
287
+ }
288
+ return next;
289
+ }
290
+ catch {
291
+ return new Map();
292
+ }
293
+ }
294
+ function persistStoredSelectionPreviewsByThread(previewsByThread) {
295
+ const serialized = {};
296
+ const entries = Array.from(previewsByThread.entries()).slice(-MAX_STORED_SELECTION_PREVIEW_THREADS);
297
+ for (const [threadId, threadEntries] of entries) {
298
+ if (!threadId || !Array.isArray(threadEntries) || threadEntries.length <= 0) {
299
+ continue;
300
+ }
301
+ const normalizedEntries = threadEntries
302
+ .filter(item => item && typeof item.contentHash === 'string' && item.contentHash)
303
+ .slice(-MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD)
304
+ .map(item => item.preview
305
+ ? { contentHash: item.contentHash, preview: item.preview }
306
+ : { contentHash: item.contentHash });
307
+ if (normalizedEntries.length > 0) {
308
+ serialized[threadId] = normalizedEntries;
309
+ }
310
+ }
311
+ safeLocalStorageSet(SELECTION_PREVIEWS_STORAGE_KEY, JSON.stringify(serialized));
312
+ }
313
+ function coerceSelectionPreview(value) {
314
+ return coerceSelectionPreviewShared(value, {
315
+ normalize: normalizeSelectionPreviewText,
316
+ maxChars: MESSAGE_SELECTION_PREVIEW_STORED_MAX_CHARS
317
+ });
318
+ }
319
+ function coerceMessageContextPreview(value) {
320
+ return coerceMessageContextPreviewShared(value, {
321
+ normalize: normalizeSelectionPreviewText,
322
+ maxChars: MESSAGE_SELECTION_PREVIEW_STORED_MAX_CHARS
323
+ });
324
+ }
325
+ function coerceSessionHistory(raw) {
326
+ return coerceSessionHistoryShared(raw, {
327
+ normalize: normalizeSelectionPreviewText,
328
+ maxChars: MESSAGE_SELECTION_PREVIEW_STORED_MAX_CHARS
329
+ });
330
+ }
331
+ function createSession(path, intro, options) {
332
+ const session = createBaseSession(path, intro, options, {
333
+ readStoredThreadId,
334
+ readDefaultModelOption,
335
+ readDefaultReasoningEffortOption,
336
+ readDefaultSandboxModeOption,
337
+ normalizeSystemText
338
+ });
339
+ return markLocalizedStartNotice(session);
340
+ }
341
+ function createThreadResetSession(path, sessionKey, threadId) {
342
+ const session = createBaseThreadResetSession(path, sessionKey, threadId, {
343
+ readStoredThreadId,
344
+ readDefaultModelOption,
345
+ readDefaultReasoningEffortOption,
346
+ readDefaultSandboxModeOption,
347
+ normalizeSystemText
348
+ });
349
+ return markLocalizedStartNotice(session);
350
+ }
351
+ function normalizeSystemText(role, text) {
352
+ if (role !== 'system') {
353
+ return text;
354
+ }
355
+ return normalizeSessionStartedNotice(text) ?? text;
356
+ }
357
+ function readStoredModel() {
358
+ return safeLocalStorageGet(MODEL_STORAGE_KEY) ?? '';
359
+ }
360
+ function readStoredCommandPath() {
361
+ return safeLocalStorageGet(COMMAND_PATH_STORAGE_KEY) ?? '';
362
+ }
363
+ function readStoredAutoSave() {
364
+ return (safeLocalStorageGet(AUTO_SAVE_STORAGE_KEY) ?? 'true') !== 'false';
365
+ }
366
+ function readStoredIncludeActiveCell() {
367
+ return (safeLocalStorageGet(INCLUDE_ACTIVE_CELL_STORAGE_KEY) ?? 'true') !== 'false';
368
+ }
369
+ function readStoredIncludeActiveCellOutput() {
370
+ return (safeLocalStorageGet(INCLUDE_ACTIVE_CELL_OUTPUT_STORAGE_KEY) ?? 'true') !== 'false';
371
+ }
372
+ function readStoredReasoningEffort() {
373
+ try {
374
+ const stored = safeLocalStorageGet(REASONING_STORAGE_KEY) ?? '';
375
+ if (stored === '__config__') {
376
+ return '__config__';
377
+ }
378
+ const normalized = stored.trim();
379
+ return normalized ? normalized : '__config__';
380
+ }
381
+ catch {
382
+ return '__config__';
383
+ }
384
+ }
385
+ function readStoredSandboxMode() {
386
+ const stored = safeLocalStorageGet(SANDBOX_MODE_STORAGE_KEY) ?? '';
387
+ return isKnownSandboxMode(stored) ? stored : SANDBOX_OPTIONS[0].value;
388
+ }
389
+ function persistModel(model) {
390
+ safeLocalStorageSet(MODEL_STORAGE_KEY, model);
391
+ }
392
+ function persistCommandPath(commandPath) {
393
+ safeLocalStorageSet(COMMAND_PATH_STORAGE_KEY, commandPath);
394
+ }
395
+ function persistAutoSave(enabled) {
396
+ safeLocalStorageSet(AUTO_SAVE_STORAGE_KEY, enabled ? 'true' : 'false');
397
+ }
398
+ function persistIncludeActiveCell(enabled) {
399
+ safeLocalStorageSet(INCLUDE_ACTIVE_CELL_STORAGE_KEY, enabled ? 'true' : 'false');
400
+ }
401
+ function persistIncludeActiveCellOutput(enabled) {
402
+ safeLocalStorageSet(INCLUDE_ACTIVE_CELL_OUTPUT_STORAGE_KEY, enabled ? 'true' : 'false');
403
+ }
404
+ function persistReasoningEffort(value) {
405
+ safeLocalStorageSet(REASONING_STORAGE_KEY, value);
406
+ }
407
+ function persistSandboxMode(value) {
408
+ safeLocalStorageSet(SANDBOX_MODE_STORAGE_KEY, value);
409
+ }
410
+ function readStoredSettingsOpen() {
411
+ return (safeLocalStorageGet(SETTINGS_OPEN_STORAGE_KEY) ?? 'false') === 'true';
412
+ }
413
+ function persistSettingsOpen(enabled) {
414
+ safeLocalStorageSet(SETTINGS_OPEN_STORAGE_KEY, enabled ? 'true' : 'false');
415
+ }
416
+ function readStoredNotifyOnDone() {
417
+ return (safeLocalStorageGet(NOTIFY_ON_DONE_STORAGE_KEY) ?? 'true') === 'true';
418
+ }
419
+ function persistNotifyOnDone(enabled) {
420
+ safeLocalStorageSet(NOTIFY_ON_DONE_STORAGE_KEY, enabled ? 'true' : 'false');
421
+ }
422
+ function readStoredNotifyOnDoneMinSeconds() {
423
+ const raw = safeLocalStorageGet(NOTIFY_ON_DONE_MIN_SECONDS_STORAGE_KEY);
424
+ if (raw == null) {
425
+ return 30;
426
+ }
427
+ const parsed = Number(raw);
428
+ return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : 0;
429
+ }
430
+ function persistNotifyOnDoneMinSeconds(seconds) {
431
+ safeLocalStorageSet(NOTIFY_ON_DONE_MIN_SECONDS_STORAGE_KEY, String(Math.max(0, Math.trunc(seconds))));
432
+ }
433
+ function CodexChat(props) {
434
+ const [sessions, setSessions] = useState(new Map());
435
+ const sessionsRef = useRef(new Map());
436
+ const [currentNotebookPath, setCurrentNotebookPath] = useState('');
437
+ const currentNotebookPathRef = useRef('');
438
+ const [currentNotebookSessionKey, setCurrentNotebookSessionKey] = useState('');
439
+ const currentNotebookSessionKeyRef = useRef('');
440
+ const [cliDefaults, setCliDefaults] = useState({ model: null, reasoningEffort: null });
441
+ const [modelOptions, setModelOptions] = useState(() => FALLBACK_MODEL_OPTIONS);
442
+ const [modelOption, setModelOption] = useState(() => readDefaultModelOption());
443
+ const [reasoningEffort, setReasoningEffort] = useState(() => readDefaultReasoningEffortOption());
444
+ const [sandboxMode, setSandboxMode] = useState(() => readDefaultSandboxModeOption());
445
+ const [commandPath, setCommandPath] = useState(() => readStoredCommandPath());
446
+ const [autoSaveBeforeSend, setAutoSaveBeforeSend] = useState(() => readStoredAutoSave());
447
+ const [includeActiveCell, setIncludeActiveCell] = useState(() => readStoredIncludeActiveCell());
448
+ const [includeActiveCellOutput, setIncludeActiveCellOutput] = useState(() => readStoredIncludeActiveCellOutput());
449
+ const [currentDocumentIsNotebookEditor, setCurrentDocumentIsNotebookEditor] = useState(false);
450
+ const [notifyOnDone, setNotifyOnDone] = useState(() => readStoredNotifyOnDone());
451
+ const [notifyOnDoneMinSeconds, setNotifyOnDoneMinSeconds] = useState(() => readStoredNotifyOnDoneMinSeconds());
452
+ const [settingsOpen, setSettingsOpen] = useState(() => readStoredSettingsOpen());
453
+ const [input, setInput] = useState('');
454
+ const [pendingImages, setPendingImages] = useState([]);
455
+ const pendingImagesRef = useRef([]);
456
+ const inputRef = useRef('');
457
+ const inputDraftsRef = useRef(new Map());
458
+ const { socketRef: wsRef, socketConnected, isReconnecting, reconnect: reconnectSocket } = useCodexSocket({
459
+ onOpen: onSocketOpen,
460
+ onClose: onSocketClose,
461
+ onMessage: onSocketMessage
462
+ });
463
+ const [isAtBottom, setIsAtBottom] = useState(true);
464
+ const [modelMenuOpen, setModelMenuOpen] = useState(false);
465
+ const [reasoningMenuOpen, setReasoningMenuOpen] = useState(false);
466
+ const [usagePopoverOpen, setUsagePopoverOpen] = useState(false);
467
+ const [permissionMenuOpen, setPermissionMenuOpen] = useState(false);
468
+ const [contextPopoverOpen, setContextPopoverOpen] = useState(false);
469
+ const [isPlainPyRunInProgress, setIsPlainPyRunInProgress] = useState(false);
470
+ const [cellAttachmentPopoverOpen, setCellAttachmentPopoverOpen] = useState(false);
471
+ const [selectionPopover, setSelectionPopover] = useState(null);
472
+ const storedSelectionPreviewsRef = useRef(readStoredSelectionPreviewsByThread());
473
+ const previousSessionThreadIdsRef = useRef(new Map());
474
+ const [rateLimits, setRateLimits] = useState(null);
475
+ const runToSessionKeyRef = useRef(new Map());
476
+ const activeSessionKeyByPathRef = useRef(new Map());
477
+ const sessionThreadSyncIdRef = useRef(createSessionEventId());
478
+ const lastRateLimitsRefreshRef = useRef(0);
479
+ const pendingRefreshPathsRef = useRef(new Set());
480
+ const activeDocumentWidgetRef = useRef(null);
481
+ const socketMessageQueueRef = useRef([]);
482
+ const socketMessageFlushTimerRef = useRef(null);
483
+ const socketMessageFlushRafRef = useRef(null);
484
+ const lastActiveCellAttachmentSignatureRef = useRef(new Map());
485
+ const notifyOnDoneRef = useRef(notifyOnDone);
486
+ const notifyOnDoneMinSecondsRef = useRef(notifyOnDoneMinSeconds);
487
+ const scrollRef = useRef(null);
488
+ const endRef = useRef(null);
489
+ const modelMenuWrapRef = useRef(null);
490
+ const reasoningMenuWrapRef = useRef(null);
491
+ const usageMenuWrapRef = useRef(null);
492
+ const permissionMenuWrapRef = useRef(null);
493
+ const contextMenuWrapRef = useRef(null);
494
+ const modelBtnRef = useRef(null);
495
+ const modelPopoverRef = useRef(null);
496
+ const reasoningBtnRef = useRef(null);
497
+ const reasoningPopoverRef = useRef(null);
498
+ const usageBtnRef = useRef(null);
499
+ const usagePopoverRef = useRef(null);
500
+ const permissionBtnRef = useRef(null);
501
+ const permissionPopoverRef = useRef(null);
502
+ const contextBtnRef = useRef(null);
503
+ const contextPopoverRef = useRef(null);
504
+ const plainPyRunSessionKeyRef = useRef('');
505
+ const cellAttachmentAnchorRef = useRef(null);
506
+ const cellAttachmentPopoverRef = useRef(null);
507
+ const cellAttachmentPopoverCloseTimerRef = useRef(null);
508
+ const contextPopoverCloseTimerRef = useRef(null);
509
+ const selectionPopoverAnchorRef = useRef(null);
510
+ const selectionPopoverRef = useRef(null);
511
+ const notebookLabelRef = useRef(null);
512
+ const [isNotebookLabelTruncated, setIsNotebookLabelTruncated] = useState(false);
513
+ const composerTextareaRef = useRef(null);
514
+ const storedThreadCount = useMemo(() => getStoredSessionThreadCount(), [sessions]);
515
+ const selectedModel = modelOption === '__config__' ? '' : modelOption;
516
+ const autoModel = cliDefaults.model;
517
+ const autoReasoningEffort = cliDefaults.reasoningEffort;
518
+ const reasoningModel = modelOption === '__config__' ? (autoModel || '') : modelOption;
519
+ const reasoningOptions = useMemo(() => buildReasoningOptions(cliDefaults.availableModels, reasoningModel), [cliDefaults.availableModels, reasoningModel]);
520
+ useEffect(() => {
521
+ const dynamicModelOptions = readModelOptions(cliDefaults.availableModels);
522
+ setModelOptions(dynamicModelOptions);
523
+ }, [cliDefaults.availableModels]);
524
+ useEffect(() => {
525
+ if (reasoningEffort === '__config__') {
526
+ return;
527
+ }
528
+ if (reasoningOptions.length > 0 && !reasoningOptions.some(option => option.value === reasoningEffort)) {
529
+ setCurrentSessionReasoningEffort('__config__');
530
+ }
531
+ }, [reasoningEffort, reasoningOptions]);
532
+ useEffect(() => {
533
+ if (reasoningEffort !== '__config__') {
534
+ return;
535
+ }
536
+ if (coerceReasoningEffort(autoReasoningEffort || '')) {
537
+ return;
538
+ }
539
+ const fallbackReasoning = resolveFallbackReasoningEffort(reasoningOptions);
540
+ setCurrentSessionReasoningEffort(fallbackReasoning);
541
+ }, [reasoningEffort, autoReasoningEffort, reasoningOptions]);
542
+ useEffect(() => {
543
+ if (modelOption === '__config__') {
544
+ return;
545
+ }
546
+ if (!modelOptions.some(option => option.value === modelOption)) {
547
+ setCurrentSessionModelOption('__config__');
548
+ }
549
+ }, [modelOption, modelOptions]);
550
+ useEffect(() => {
551
+ if (modelOption !== '__config__') {
552
+ return;
553
+ }
554
+ if ((autoModel || '').trim()) {
555
+ return;
556
+ }
557
+ const firstModel = modelOptions[0]?.value;
558
+ if (!firstModel) {
559
+ return;
560
+ }
561
+ setCurrentSessionModelOption(firstModel);
562
+ }, [modelOption, autoModel, modelOptions]);
563
+ function setInputDraftForSession(sessionKey, value) {
564
+ if (!sessionKey) {
565
+ return;
566
+ }
567
+ const nextDrafts = new Map(inputDraftsRef.current);
568
+ if (value) {
569
+ nextDrafts.set(sessionKey, value);
570
+ }
571
+ else {
572
+ nextDrafts.delete(sessionKey);
573
+ }
574
+ inputDraftsRef.current = nextDrafts;
575
+ }
576
+ function saveCurrentInputDraft(value) {
577
+ const sessionKey = currentNotebookSessionKeyRef.current || '';
578
+ if (!sessionKey) {
579
+ return;
580
+ }
581
+ setInputDraftForSession(sessionKey, value);
582
+ }
583
+ function clearInputForCurrentSession() {
584
+ const sessionKey = currentNotebookSessionKeyRef.current || '';
585
+ if (!sessionKey) {
586
+ setInput('');
587
+ inputRef.current = '';
588
+ return;
589
+ }
590
+ saveCurrentInputDraft('');
591
+ setInput('');
592
+ inputRef.current = '';
593
+ }
594
+ function updateInput(nextValue) {
595
+ saveCurrentInputDraft(nextValue);
596
+ setInput(nextValue);
597
+ inputRef.current = nextValue;
598
+ }
599
+ function restoreInput(sessionKey) {
600
+ const restoredInput = sessionKey ? inputDraftsRef.current.get(sessionKey) || '' : '';
601
+ inputRef.current = restoredInput;
602
+ setInput(restoredInput);
603
+ }
604
+ useEffect(() => {
605
+ inputRef.current = input;
606
+ }, [input]);
607
+ useEffect(() => {
608
+ sessionsRef.current = sessions;
609
+ currentNotebookSessionKeyRef.current = currentNotebookSessionKey;
610
+ }, [sessions]);
611
+ useEffect(() => {
612
+ currentNotebookSessionKeyRef.current = currentNotebookSessionKey;
613
+ persistStoredSessionThreads(sessions);
614
+ }, [sessions, currentNotebookSessionKey]);
615
+ useEffect(() => {
616
+ const sessionKey = currentNotebookSessionKey || '';
617
+ if (!sessionKey) {
618
+ return;
619
+ }
620
+ const session = sessions.get(sessionKey);
621
+ if (!session) {
622
+ return;
623
+ }
624
+ setModelOption(prev => (prev === session.selectedModelOption ? prev : session.selectedModelOption));
625
+ setReasoningEffort(prev => prev === session.selectedReasoningEffort ? prev : session.selectedReasoningEffort);
626
+ setSandboxMode(prev => (prev === session.selectedSandboxMode ? prev : session.selectedSandboxMode));
627
+ }, [currentNotebookSessionKey, sessions]);
628
+ useEffect(() => {
629
+ pendingImagesRef.current = pendingImages;
630
+ }, [pendingImages]);
631
+ useEffect(() => {
632
+ return () => {
633
+ // Avoid leaking blob URLs if the panel unmounts.
634
+ for (const image of pendingImagesRef.current) {
635
+ URL.revokeObjectURL(image.previewUrl);
636
+ }
637
+ };
638
+ }, []);
639
+ useEffect(() => {
640
+ persistModel(selectedModel);
641
+ }, [selectedModel]);
642
+ useEffect(() => {
643
+ persistAutoSave(autoSaveBeforeSend);
644
+ }, [autoSaveBeforeSend]);
645
+ useEffect(() => {
646
+ persistIncludeActiveCell(includeActiveCell);
647
+ }, [includeActiveCell]);
648
+ useEffect(() => {
649
+ // If the user enables "Include active cell" for the first time, default output to ON as well.
650
+ if (includeActiveCell && !includeActiveCellOutput && !hasStoredValue(INCLUDE_ACTIVE_CELL_OUTPUT_STORAGE_KEY)) {
651
+ setIncludeActiveCellOutput(true);
652
+ }
653
+ }, [includeActiveCell, includeActiveCellOutput]);
654
+ useEffect(() => {
655
+ persistIncludeActiveCellOutput(includeActiveCellOutput);
656
+ }, [includeActiveCellOutput]);
657
+ useEffect(() => {
658
+ persistCommandPath(commandPath);
659
+ }, [commandPath]);
660
+ useEffect(() => {
661
+ persistReasoningEffort(reasoningEffort);
662
+ }, [reasoningEffort]);
663
+ useEffect(() => {
664
+ persistSandboxMode(sandboxMode);
665
+ }, [sandboxMode]);
666
+ useEffect(() => {
667
+ persistSettingsOpen(settingsOpen);
668
+ }, [settingsOpen]);
669
+ useEffect(() => {
670
+ notifyOnDoneRef.current = notifyOnDone;
671
+ persistNotifyOnDone(notifyOnDone);
672
+ }, [notifyOnDone]);
673
+ useEffect(() => {
674
+ const normalized = Number.isFinite(notifyOnDoneMinSeconds) ? Math.max(0, Math.floor(notifyOnDoneMinSeconds)) : 0;
675
+ notifyOnDoneMinSecondsRef.current = normalized;
676
+ persistNotifyOnDoneMinSeconds(normalized);
677
+ }, [notifyOnDoneMinSeconds]);
678
+ useEffect(() => {
679
+ if (!modelMenuOpen && !reasoningMenuOpen && !usagePopoverOpen && !permissionMenuOpen && !contextPopoverOpen) {
680
+ return;
681
+ }
682
+ const onPointerDown = (event) => {
683
+ const target = event.target;
684
+ if (!target) {
685
+ return;
686
+ }
687
+ const inModel = modelMenuWrapRef.current?.contains(target) ?? false;
688
+ const inReasoning = reasoningMenuWrapRef.current?.contains(target) ?? false;
689
+ const inUsage = usageMenuWrapRef.current?.contains(target) ?? false;
690
+ const inPermission = permissionMenuWrapRef.current?.contains(target) ?? false;
691
+ const inContext = contextMenuWrapRef.current?.contains(target) ?? false;
692
+ const inModelPopover = modelPopoverRef.current?.contains(target) ?? false;
693
+ const inReasoningPopover = reasoningPopoverRef.current?.contains(target) ?? false;
694
+ const inUsagePopover = usagePopoverRef.current?.contains(target) ?? false;
695
+ const inPermissionPopover = permissionPopoverRef.current?.contains(target) ?? false;
696
+ const inContextPopover = contextPopoverRef.current?.contains(target) ?? false;
697
+ if (inModel ||
698
+ inReasoning ||
699
+ inUsage ||
700
+ inPermission ||
701
+ inContext ||
702
+ inModelPopover ||
703
+ inReasoningPopover ||
704
+ inUsagePopover ||
705
+ inPermissionPopover ||
706
+ inContextPopover) {
707
+ return;
708
+ }
709
+ setModelMenuOpen(false);
710
+ setReasoningMenuOpen(false);
711
+ setUsagePopoverOpen(false);
712
+ setPermissionMenuOpen(false);
713
+ setContextPopoverOpen(false);
714
+ };
715
+ const onKeyDown = (event) => {
716
+ if (event.key !== 'Escape') {
717
+ return;
718
+ }
719
+ event.preventDefault();
720
+ setModelMenuOpen(false);
721
+ setReasoningMenuOpen(false);
722
+ setUsagePopoverOpen(false);
723
+ setPermissionMenuOpen(false);
724
+ setContextPopoverOpen(false);
725
+ };
726
+ window.addEventListener('pointerdown', onPointerDown, true);
727
+ window.addEventListener('keydown', onKeyDown);
728
+ return () => {
729
+ window.removeEventListener('pointerdown', onPointerDown, true);
730
+ window.removeEventListener('keydown', onKeyDown);
731
+ };
732
+ }, [modelMenuOpen, reasoningMenuOpen, usagePopoverOpen, permissionMenuOpen, contextPopoverOpen]);
733
+ useEffect(() => {
734
+ if (!selectionPopover) {
735
+ return;
736
+ }
737
+ const onPointerDown = (event) => {
738
+ const target = event.target;
739
+ if (!target) {
740
+ return;
741
+ }
742
+ const inAnchor = selectionPopoverAnchorRef.current?.contains(target) ?? false;
743
+ const inPopover = selectionPopoverRef.current?.contains(target) ?? false;
744
+ if (inAnchor || inPopover) {
745
+ return;
746
+ }
747
+ setSelectionPopover(null);
748
+ selectionPopoverAnchorRef.current = null;
749
+ };
750
+ const onKeyDown = (event) => {
751
+ if (event.key !== 'Escape') {
752
+ return;
753
+ }
754
+ event.preventDefault();
755
+ setSelectionPopover(null);
756
+ selectionPopoverAnchorRef.current = null;
757
+ };
758
+ window.addEventListener('pointerdown', onPointerDown, true);
759
+ window.addEventListener('keydown', onKeyDown);
760
+ return () => {
761
+ window.removeEventListener('pointerdown', onPointerDown, true);
762
+ window.removeEventListener('keydown', onKeyDown);
763
+ };
764
+ }, [selectionPopover]);
765
+ function closeSelectionPopover() {
766
+ setSelectionPopover(null);
767
+ selectionPopoverAnchorRef.current = null;
768
+ }
769
+ function clearCellAttachmentPopoverCloseTimer() {
770
+ if (cellAttachmentPopoverCloseTimerRef.current !== null) {
771
+ window.clearTimeout(cellAttachmentPopoverCloseTimerRef.current);
772
+ cellAttachmentPopoverCloseTimerRef.current = null;
773
+ }
774
+ }
775
+ function clearContextPopoverCloseTimer() {
776
+ if (contextPopoverCloseTimerRef.current !== null) {
777
+ window.clearTimeout(contextPopoverCloseTimerRef.current);
778
+ contextPopoverCloseTimerRef.current = null;
779
+ }
780
+ }
781
+ function openCellAttachmentPopover() {
782
+ clearCellAttachmentPopoverCloseTimer();
783
+ if (!showCellAttachmentBadge) {
784
+ setCellAttachmentPopoverOpen(false);
785
+ return;
786
+ }
787
+ setCellAttachmentPopoverOpen(true);
788
+ }
789
+ function scheduleCloseCellAttachmentPopover() {
790
+ clearCellAttachmentPopoverCloseTimer();
791
+ cellAttachmentPopoverCloseTimerRef.current = window.setTimeout(() => {
792
+ setCellAttachmentPopoverOpen(false);
793
+ cellAttachmentPopoverCloseTimerRef.current = null;
794
+ }, 90);
795
+ }
796
+ function openContextPopover() {
797
+ clearContextPopoverCloseTimer();
798
+ if (!hasContextUsageSnapshot) {
799
+ setContextPopoverOpen(false);
800
+ return;
801
+ }
802
+ setContextPopoverOpen(true);
803
+ }
804
+ function scheduleCloseContextPopover() {
805
+ clearContextPopoverCloseTimer();
806
+ contextPopoverCloseTimerRef.current = window.setTimeout(() => {
807
+ setContextPopoverOpen(false);
808
+ contextPopoverCloseTimerRef.current = null;
809
+ }, 90);
810
+ }
811
+ function handleContextPopoverBlur(event) {
812
+ const nextFocused = event.relatedTarget;
813
+ const inAnchor = nextFocused ? contextMenuWrapRef.current?.contains(nextFocused) ?? false : false;
814
+ const inPopover = nextFocused ? contextPopoverRef.current?.contains(nextFocused) ?? false : false;
815
+ if (inAnchor || inPopover) {
816
+ return;
817
+ }
818
+ scheduleCloseContextPopover();
819
+ }
820
+ function handleCellAttachmentBlur(event) {
821
+ const nextFocused = event.relatedTarget;
822
+ const inAnchor = nextFocused ? cellAttachmentAnchorRef.current?.contains(nextFocused) ?? false : false;
823
+ const inPopover = nextFocused ? cellAttachmentPopoverRef.current?.contains(nextFocused) ?? false : false;
824
+ if (inAnchor || inPopover) {
825
+ return;
826
+ }
827
+ scheduleCloseCellAttachmentPopover();
828
+ }
829
+ function toggleSelectionPopover(messageId, preview, event) {
830
+ if (!messageId) {
831
+ return;
832
+ }
833
+ if (selectionPopover?.messageId === messageId) {
834
+ closeSelectionPopover();
835
+ return;
836
+ }
837
+ selectionPopoverAnchorRef.current = event.currentTarget;
838
+ setSelectionPopover({ messageId, preview });
839
+ }
840
+ function commitStoredSelectionPreviews(nextByThread) {
841
+ const trimmed = new Map();
842
+ const entries = Array.from(nextByThread.entries()).slice(-MAX_STORED_SELECTION_PREVIEW_THREADS);
843
+ for (const [threadId, threadEntries] of entries) {
844
+ if (!threadId || !Array.isArray(threadEntries) || threadEntries.length <= 0) {
845
+ continue;
846
+ }
847
+ trimmed.set(threadId, threadEntries.slice(-MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD));
848
+ }
849
+ storedSelectionPreviewsRef.current = trimmed;
850
+ persistStoredSelectionPreviewsByThread(trimmed);
851
+ }
852
+ function appendStoredSelectionPreviewEntry(threadId, content, preview) {
853
+ const normalizedThreadId = (threadId || '').trim();
854
+ if (!normalizedThreadId) {
855
+ return;
856
+ }
857
+ const hash = hashSelectionPreviewContent(content || '');
858
+ if (!hash) {
859
+ return;
860
+ }
861
+ const next = new Map(storedSelectionPreviewsRef.current);
862
+ const existing = next.get(normalizedThreadId) ?? [];
863
+ const entry = {
864
+ contentHash: hash,
865
+ preview: preview ?? null
866
+ };
867
+ next.delete(normalizedThreadId);
868
+ next.set(normalizedThreadId, [...existing, entry].slice(-MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD));
869
+ commitStoredSelectionPreviews(next);
870
+ }
871
+ function migrateStoredSelectionPreviewEntries(fromThreadId, toThreadId) {
872
+ const from = (fromThreadId || '').trim();
873
+ const to = (toThreadId || '').trim();
874
+ if (!from || !to || from === to) {
875
+ return;
876
+ }
877
+ const current = storedSelectionPreviewsRef.current;
878
+ const sourceEntries = current.get(from);
879
+ if (!sourceEntries || sourceEntries.length <= 0) {
880
+ return;
881
+ }
882
+ const next = new Map(current);
883
+ const targetEntries = next.get(to) ?? [];
884
+ next.delete(from);
885
+ next.delete(to);
886
+ next.set(to, [...targetEntries, ...sourceEntries].slice(-MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD));
887
+ commitStoredSelectionPreviews(next);
888
+ }
889
+ function clearStoredSelectionPreviews() {
890
+ storedSelectionPreviewsRef.current = new Map();
891
+ safeLocalStorageRemove(SELECTION_PREVIEWS_STORAGE_KEY);
892
+ }
893
+ function replaceSessions(next) {
894
+ sessionsRef.current = next;
895
+ setSessions(next);
896
+ }
897
+ function trimSessionMessages(messages) {
898
+ if (messages.length <= MAX_SESSION_MESSAGES) {
899
+ return messages;
900
+ }
901
+ return messages.slice(messages.length - MAX_SESSION_MESSAGES);
902
+ }
903
+ function updateSessions(updater) {
904
+ setSessions(prev => {
905
+ const next = updater(prev);
906
+ sessionsRef.current = next;
907
+ return next;
908
+ });
909
+ }
910
+ function ensureSession(path, sessionKey) {
911
+ const normalizedPath = path || '';
912
+ const effectiveSessionKey = sessionKey || resolveCurrentSessionKey(normalizedPath);
913
+ const existing = sessionsRef.current.get(effectiveSessionKey);
914
+ if (existing) {
915
+ return existing;
916
+ }
917
+ const created = createSession(normalizedPath, `Session started`, { sessionKey: effectiveSessionKey });
918
+ const seeded = {
919
+ ...created,
920
+ selectedModelOption: modelOption,
921
+ selectedReasoningEffort: reasoningEffort,
922
+ selectedSandboxMode: sandboxMode,
923
+ };
924
+ const next = new Map(sessionsRef.current);
925
+ next.set(effectiveSessionKey, seeded);
926
+ activeSessionKeyByPathRef.current.set(normalizedPath, effectiveSessionKey);
927
+ replaceSessions(next);
928
+ return seeded;
929
+ }
930
+ function updateSessionSelection(sessionKey, selection) {
931
+ const targetSessionKey = sessionKey || currentNotebookSessionKeyRef.current || '';
932
+ if (!targetSessionKey) {
933
+ return;
934
+ }
935
+ updateSessions(prev => {
936
+ const next = new Map(prev);
937
+ const existing = next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
938
+ const selectedModelOption = selection.selectedModelOption ?? existing.selectedModelOption;
939
+ const selectedReasoningEffort = selection.selectedReasoningEffort ?? existing.selectedReasoningEffort;
940
+ const selectedSandboxMode = selection.selectedSandboxMode ?? existing.selectedSandboxMode;
941
+ const effectiveSandboxMode = selection.effectiveSandboxMode ?? existing.effectiveSandboxMode;
942
+ if (selectedModelOption === existing.selectedModelOption &&
943
+ selectedReasoningEffort === existing.selectedReasoningEffort &&
944
+ selectedSandboxMode === existing.selectedSandboxMode &&
945
+ effectiveSandboxMode === existing.effectiveSandboxMode) {
946
+ return prev;
947
+ }
948
+ next.set(targetSessionKey, {
949
+ ...existing,
950
+ selectedModelOption,
951
+ selectedReasoningEffort,
952
+ selectedSandboxMode,
953
+ effectiveSandboxMode,
954
+ });
955
+ return next;
956
+ });
957
+ }
958
+ function setCurrentSessionModelOption(nextValue) {
959
+ setModelOption(nextValue);
960
+ updateSessionSelection(currentNotebookSessionKeyRef.current || '', { selectedModelOption: nextValue });
961
+ }
962
+ function setCurrentSessionReasoningEffort(nextValue) {
963
+ setReasoningEffort(nextValue);
964
+ updateSessionSelection(currentNotebookSessionKeyRef.current || '', { selectedReasoningEffort: nextValue });
965
+ }
966
+ function setCurrentSessionSandboxMode(nextValue) {
967
+ setSandboxMode(nextValue);
968
+ updateSessionSelection(currentNotebookSessionKeyRef.current || '', { selectedSandboxMode: nextValue });
969
+ }
970
+ function requestRateLimitsRefresh(force = false) {
971
+ const socket = wsRef.current;
972
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
973
+ return;
974
+ }
975
+ const now = Date.now();
976
+ if (!force && now - lastRateLimitsRefreshRef.current < 15000) {
977
+ return;
978
+ }
979
+ lastRateLimitsRefreshRef.current = now;
980
+ safeSocketSend(JSON.stringify({ type: 'refresh_rate_limits' }));
981
+ }
982
+ function safeSocketSend(payload) {
983
+ const socket = wsRef.current;
984
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
985
+ return false;
986
+ }
987
+ try {
988
+ socket.send(payload);
989
+ }
990
+ catch {
991
+ return false;
992
+ }
993
+ return true;
994
+ }
995
+ function toggleUsagePopover() {
996
+ setUsagePopoverOpen(open => {
997
+ const next = !open;
998
+ if (next) {
999
+ requestRateLimitsRefresh();
1000
+ }
1001
+ return next;
1002
+ });
1003
+ setModelMenuOpen(false);
1004
+ setReasoningMenuOpen(false);
1005
+ setPermissionMenuOpen(false);
1006
+ setContextPopoverOpen(false);
1007
+ }
1008
+ async function updateNotifyOnDone(enabled) {
1009
+ if (!enabled) {
1010
+ setNotifyOnDone(false);
1011
+ return;
1012
+ }
1013
+ const permission = getBrowserNotificationPermission();
1014
+ const systemSessionKey = currentNotebookSessionKeyRef.current || '';
1015
+ if (permission === 'unsupported') {
1016
+ appendMessage(systemSessionKey, 'system', 'Browser notifications are not supported in this environment.');
1017
+ setNotifyOnDone(false);
1018
+ return;
1019
+ }
1020
+ if (permission === 'granted') {
1021
+ setNotifyOnDone(true);
1022
+ return;
1023
+ }
1024
+ if (permission === 'denied') {
1025
+ appendMessage(systemSessionKey, 'system', 'Browser notifications are blocked for this site. Allow notifications in browser settings to enable this option.');
1026
+ setNotifyOnDone(false);
1027
+ return;
1028
+ }
1029
+ try {
1030
+ const requested = await window.Notification.requestPermission();
1031
+ if (requested === 'granted') {
1032
+ setNotifyOnDone(true);
1033
+ return;
1034
+ }
1035
+ if (requested === 'denied') {
1036
+ appendMessage(systemSessionKey, 'system', 'Browser notification permission was denied. Allow notifications in browser settings to enable this option.');
1037
+ }
1038
+ setNotifyOnDone(false);
1039
+ }
1040
+ catch {
1041
+ appendMessage(systemSessionKey, 'system', 'Failed to request browser notification permission.');
1042
+ setNotifyOnDone(false);
1043
+ }
1044
+ }
1045
+ function getRunDurationMs(sessionKey) {
1046
+ if (!sessionKey) {
1047
+ return null;
1048
+ }
1049
+ const session = sessionsRef.current.get(sessionKey);
1050
+ if (!session || typeof session.runStartedAt !== 'number' || !Number.isFinite(session.runStartedAt)) {
1051
+ return null;
1052
+ }
1053
+ return Math.max(0, Date.now() - session.runStartedAt);
1054
+ }
1055
+ function formatElapsedForNotification(elapsedMs) {
1056
+ const totalSeconds = Math.round(Math.max(0, elapsedMs) / 1000);
1057
+ const minutes = Math.floor(totalSeconds / 60);
1058
+ const seconds = totalSeconds % 60;
1059
+ if (minutes === 0) {
1060
+ return `${totalSeconds}s`;
1061
+ }
1062
+ return `${minutes}m ${seconds}s`;
1063
+ }
1064
+ function notifyRunDone(sessionKey, notebookPath, cancelled, exitCode) {
1065
+ if (!notifyOnDoneRef.current) {
1066
+ return;
1067
+ }
1068
+ const permission = getBrowserNotificationPermission();
1069
+ if (permission !== 'granted' || typeof window === 'undefined' || typeof window.Notification === 'undefined') {
1070
+ return;
1071
+ }
1072
+ const elapsedMs = getRunDurationMs(sessionKey);
1073
+ const minimumMs = notifyOnDoneMinSecondsRef.current * 1000;
1074
+ if (minimumMs > 0 && (elapsedMs === null || elapsedMs < minimumMs)) {
1075
+ return;
1076
+ }
1077
+ const parsed = parseSessionKey(sessionKey);
1078
+ const pathLabel = parsed.path || notebookPath || currentNotebookPathRef.current || 'current notebook';
1079
+ const pathSummary = truncateMiddle(pathLabel, 120);
1080
+ const elapsedText = elapsedMs === null ? '' : ` (${formatElapsedForNotification(elapsedMs)} elapsed)`;
1081
+ const body = cancelled
1082
+ ? `Run cancelled in ${pathSummary}${elapsedText}`
1083
+ : exitCode === null || exitCode === 0
1084
+ ? `Run completed in ${pathSummary}${elapsedText}`
1085
+ : `Run failed (exit ${exitCode}) in ${pathSummary}${elapsedText}`;
1086
+ try {
1087
+ const notification = new window.Notification('Codex run finished', {
1088
+ body,
1089
+ tag: sessionKey ? `codex-done-${sessionKey}` : undefined
1090
+ });
1091
+ window.setTimeout(() => notification.close(), 12000);
1092
+ }
1093
+ catch {
1094
+ // Ignore failures; completion is still shown in the panel.
1095
+ }
1096
+ }
1097
+ function emitSessionThreadEvent(sessionKey, notebookPath, threadId) {
1098
+ const source = sessionThreadSyncIdRef.current;
1099
+ const payload = buildSessionThreadSyncEvent({
1100
+ sessionKey,
1101
+ notebookPath,
1102
+ threadId,
1103
+ source,
1104
+ createEventId: createSessionEventId
1105
+ });
1106
+ writeSessionThreadSyncEvent(payload);
1107
+ }
1108
+ function sendStartSession(session, notebookPath, sessionKey, options) {
1109
+ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
1110
+ return;
1111
+ }
1112
+ const normalizedCommandPath = commandPath.trim();
1113
+ safeSocketSend(JSON.stringify(buildStartSessionMessage({
1114
+ sessionId: session.threadId,
1115
+ sessionContextKey: sessionKey,
1116
+ notebookPath,
1117
+ forceNewThread: options?.forceNewThread === true,
1118
+ commandPath: normalizedCommandPath || undefined
1119
+ })));
1120
+ }
1121
+ function syncEffectiveSandboxFromStatus(sessionKey, rawMode) {
1122
+ if (!sessionKey) {
1123
+ return;
1124
+ }
1125
+ const nextMode = coerceSandboxMode(rawMode);
1126
+ if (!nextMode) {
1127
+ return;
1128
+ }
1129
+ updateSessionSelection(sessionKey, { effectiveSandboxMode: nextMode });
1130
+ }
1131
+ function deleteAllSessionsOnServer() {
1132
+ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
1133
+ return false;
1134
+ }
1135
+ const sent = safeSocketSend(JSON.stringify(buildDeleteAllSessionsMessage()));
1136
+ if (sent) {
1137
+ return true;
1138
+ }
1139
+ return false;
1140
+ }
1141
+ function setSessionRunState(sessionKey, runState, runId) {
1142
+ if (!sessionKey) {
1143
+ return;
1144
+ }
1145
+ const targetSessionKey = sessionKey.trim();
1146
+ const isRunStart = runState === 'running';
1147
+ const isRunDone = runState === 'ready';
1148
+ let shouldClearPythonRunState = false;
1149
+ const now = Date.now();
1150
+ updateSessions(prev => {
1151
+ const next = new Map(prev);
1152
+ const existing = next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
1153
+ const session = next.get(targetSessionKey) ?? existing;
1154
+ let messages = session.messages;
1155
+ let runStartedAt = session.runStartedAt;
1156
+ let activeRunId = session.activeRunId;
1157
+ const wasRunning = session.runState === 'running';
1158
+ const hasActiveRunId = Boolean(activeRunId && activeRunId.trim());
1159
+ if (isRunStart && !runId && wasRunning && hasActiveRunId) {
1160
+ return prev;
1161
+ }
1162
+ if (isRunDone && wasRunning && hasActiveRunId) {
1163
+ if (!runId || runId !== activeRunId) {
1164
+ return prev;
1165
+ }
1166
+ }
1167
+ if (isRunStart) {
1168
+ if (!wasRunning) {
1169
+ runStartedAt = now;
1170
+ }
1171
+ if (runId) {
1172
+ activeRunId = runId;
1173
+ }
1174
+ }
1175
+ if (isRunDone && wasRunning) {
1176
+ const startedAt = runStartedAt;
1177
+ if (typeof startedAt === 'number' && Number.isFinite(startedAt)) {
1178
+ const elapsedMs = Math.max(0, now - startedAt);
1179
+ messages = [...messages, { kind: 'run-divider', id: crypto.randomUUID(), elapsedMs }];
1180
+ }
1181
+ runStartedAt = null;
1182
+ activeRunId = null;
1183
+ shouldClearPythonRunState = true;
1184
+ }
1185
+ else if (isRunDone) {
1186
+ activeRunId = null;
1187
+ }
1188
+ const progress = session.runState === runState ? session.progress : '';
1189
+ const progressKind = session.runState === runState ? session.progressKind : '';
1190
+ next.set(sessionKey, {
1191
+ ...session,
1192
+ messages: trimSessionMessages(messages),
1193
+ runState,
1194
+ activeRunId,
1195
+ runStartedAt,
1196
+ progress,
1197
+ progressKind
1198
+ });
1199
+ return next;
1200
+ });
1201
+ if (isRunStart && plainPyRunSessionKeyRef.current === targetSessionKey) {
1202
+ setIsPlainPyRunInProgress(true);
1203
+ }
1204
+ else if (isRunDone && shouldClearPythonRunState && plainPyRunSessionKeyRef.current === targetSessionKey) {
1205
+ plainPyRunSessionKeyRef.current = '';
1206
+ setIsPlainPyRunInProgress(false);
1207
+ }
1208
+ }
1209
+ function setSessionProgress(sessionKey, progress, kind = '') {
1210
+ const targetSessionKey = sessionKey || currentNotebookSessionKeyRef.current || '';
1211
+ if (!targetSessionKey) {
1212
+ return;
1213
+ }
1214
+ const nextProgress = progress ? truncateMiddle(progress, 260) : '';
1215
+ const nextKind = nextProgress ? kind : '';
1216
+ updateSessions(prev => {
1217
+ const next = new Map(prev);
1218
+ const session = next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
1219
+ if (session.progress === nextProgress && session.progressKind === nextKind) {
1220
+ return prev;
1221
+ }
1222
+ next.set(targetSessionKey, { ...session, progress: nextProgress, progressKind: nextKind });
1223
+ return next;
1224
+ });
1225
+ }
1226
+ function appendActivityItem(sessionKey, item) {
1227
+ const targetSessionKey = sessionKey || currentNotebookSessionKeyRef.current || '';
1228
+ if (!targetSessionKey) {
1229
+ return;
1230
+ }
1231
+ updateSessions(prev => {
1232
+ const next = new Map(prev);
1233
+ const session = next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
1234
+ const entry = { id: crypto.randomUUID(), ts: Date.now(), ...item };
1235
+ const extractCommandKey = (detail) => {
1236
+ const raw = (detail || '').trim();
1237
+ if (!raw) {
1238
+ return '';
1239
+ }
1240
+ // Completed entries may include extra lines (e.g. exit code).
1241
+ return raw.split('\n')[0].trim();
1242
+ };
1243
+ const normalizePhaseBaseTitle = (title) => {
1244
+ const raw = (title || '').trim();
1245
+ if (!raw) {
1246
+ return '';
1247
+ }
1248
+ return raw.replace(/\s+(started|completed)\s*$/i, '').trim();
1249
+ };
1250
+ const extractGenericKey = (title, detail) => {
1251
+ const base = normalizePhaseBaseTitle(title);
1252
+ const firstLine = (detail || '').trim().split('\n')[0].trim();
1253
+ return `${base}::${firstLine}`;
1254
+ };
1255
+ const messages = session.messages;
1256
+ // Avoid noisy duplicates like repeated "Reasoning step" lines.
1257
+ if (entry.category === 'reasoning') {
1258
+ const last = messages[messages.length - 1];
1259
+ if (last && last.kind === 'activity') {
1260
+ const previousItem = last.item;
1261
+ if (previousItem.category === entry.category &&
1262
+ previousItem.phase === entry.phase &&
1263
+ previousItem.title === entry.title &&
1264
+ previousItem.detail === entry.detail) {
1265
+ return prev;
1266
+ }
1267
+ }
1268
+ }
1269
+ // If we have a corresponding "started" command, update it in place instead of appending.
1270
+ if (entry.category === 'command' && entry.phase === 'completed') {
1271
+ const key = extractCommandKey(entry.detail);
1272
+ if (key) {
1273
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
1274
+ const msg = messages[idx];
1275
+ if (msg.kind !== 'activity') {
1276
+ continue;
1277
+ }
1278
+ const existing = msg.item;
1279
+ if (existing.category !== 'command' || existing.phase !== 'started') {
1280
+ continue;
1281
+ }
1282
+ if (extractCommandKey(existing.detail) !== key) {
1283
+ continue;
1284
+ }
1285
+ const updated = {
1286
+ ...existing,
1287
+ phase: 'completed',
1288
+ title: entry.title,
1289
+ detail: entry.detail,
1290
+ raw: entry.raw,
1291
+ };
1292
+ const updatedMessages = [
1293
+ ...messages.slice(0, idx),
1294
+ { ...msg, item: updated },
1295
+ ...messages.slice(idx + 1),
1296
+ ];
1297
+ next.set(targetSessionKey, { ...session, messages: trimSessionMessages(updatedMessages) });
1298
+ return next;
1299
+ }
1300
+ }
1301
+ }
1302
+ // Generic: If we have a corresponding "started" tool/event line, update it in place.
1303
+ // This keeps pairs like "Web Search started" -> "Web Search completed" on a single line.
1304
+ if (entry.phase === 'completed') {
1305
+ const key = extractGenericKey(entry.title, entry.detail);
1306
+ if (key) {
1307
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
1308
+ const msg = messages[idx];
1309
+ if (msg.kind !== 'activity') {
1310
+ continue;
1311
+ }
1312
+ const existing = msg.item;
1313
+ if (existing.phase !== 'started') {
1314
+ continue;
1315
+ }
1316
+ if (extractGenericKey(existing.title, existing.detail) !== key) {
1317
+ continue;
1318
+ }
1319
+ const updated = {
1320
+ ...existing,
1321
+ phase: 'completed',
1322
+ title: entry.title,
1323
+ detail: entry.detail,
1324
+ raw: entry.raw,
1325
+ };
1326
+ const updatedMessages = [
1327
+ ...messages.slice(0, idx),
1328
+ { ...msg, item: updated },
1329
+ ...messages.slice(idx + 1),
1330
+ ];
1331
+ next.set(targetSessionKey, { ...session, messages: trimSessionMessages(updatedMessages) });
1332
+ return next;
1333
+ }
1334
+ }
1335
+ }
1336
+ const updatedMessages = [...messages, { kind: 'activity', id: entry.id, item: entry }];
1337
+ next.set(targetSessionKey, { ...session, messages: trimSessionMessages(updatedMessages) });
1338
+ return next;
1339
+ });
1340
+ }
1341
+ function setSessionPairing(sessionKey, pairing) {
1342
+ const targetSessionKey = sessionKey || currentNotebookSessionKeyRef.current || '';
1343
+ if (!targetSessionKey) {
1344
+ return;
1345
+ }
1346
+ updateSessions(prev => {
1347
+ const next = new Map(prev);
1348
+ const session = next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
1349
+ next.set(targetSessionKey, {
1350
+ ...session,
1351
+ ...pairing,
1352
+ notebookMode: pairing.notebookMode ?? session.notebookMode,
1353
+ });
1354
+ return next;
1355
+ });
1356
+ }
1357
+ function setSessionConversationMode(sessionKey, rawMode) {
1358
+ const mode = coerceConversationMode(rawMode);
1359
+ if (!mode) {
1360
+ return;
1361
+ }
1362
+ const targetSessionKey = sessionKey || currentNotebookSessionKeyRef.current || '';
1363
+ if (!targetSessionKey) {
1364
+ return;
1365
+ }
1366
+ updateSessions(prev => {
1367
+ const next = new Map(prev);
1368
+ const session = next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
1369
+ if (session.conversationMode === mode) {
1370
+ return prev;
1371
+ }
1372
+ next.set(targetSessionKey, { ...session, conversationMode: mode });
1373
+ return next;
1374
+ });
1375
+ }
1376
+ function resolveMessageSessionKey(msg) {
1377
+ return resolveMessageSessionKeyForMessage({
1378
+ message: msg,
1379
+ runToSessionKey: runToSessionKeyRef.current,
1380
+ activeSessionKeyByPath: activeSessionKeyByPathRef.current,
1381
+ currentSessionKey: currentNotebookSessionKeyRef.current || ''
1382
+ });
1383
+ }
1384
+ function appendMessage(sessionKey, role, text) {
1385
+ if (!text) {
1386
+ return;
1387
+ }
1388
+ const targetSessionKey = sessionKey || currentNotebookSessionKeyRef.current || '';
1389
+ if (!targetSessionKey) {
1390
+ return;
1391
+ }
1392
+ const nextText = normalizeSystemText(role, text);
1393
+ updateSessions(prev => {
1394
+ const next = new Map(prev);
1395
+ const session = next.get(targetSessionKey) ?? createSession('', `Session started`, { sessionKey: targetSessionKey });
1396
+ const messages = session.messages;
1397
+ const updatedMessages = [
1398
+ ...messages,
1399
+ { kind: 'text', id: crypto.randomUUID(), role, text: nextText }
1400
+ ];
1401
+ next.set(targetSessionKey, { ...session, messages: trimSessionMessages(updatedMessages) });
1402
+ return next;
1403
+ });
1404
+ }
1405
+ function scrollToBottom() {
1406
+ endRef.current?.scrollIntoView({ block: 'end' });
1407
+ }
1408
+ function onScrollMessages() {
1409
+ const node = scrollRef.current;
1410
+ if (!node) {
1411
+ return;
1412
+ }
1413
+ const threshold = 80;
1414
+ const atBottom = node.scrollHeight - (node.scrollTop + node.clientHeight) < threshold;
1415
+ setIsAtBottom(atBottom);
1416
+ }
1417
+ function clearRunMappingForSessionKey(targetSessionKey) {
1418
+ const runToSessionKey = runToSessionKeyRef.current;
1419
+ const next = new Map(runToSessionKey);
1420
+ for (const [runId, mappedSessionKey] of runToSessionKey) {
1421
+ if (mappedSessionKey === targetSessionKey) {
1422
+ next.delete(runId);
1423
+ }
1424
+ }
1425
+ runToSessionKeyRef.current = next;
1426
+ }
1427
+ async function clearAllSessions() {
1428
+ const count = getStoredSessionThreadCount();
1429
+ if (!count && sessions.size === 0) {
1430
+ return;
1431
+ }
1432
+ const result = await showDialog({
1433
+ title: 'Delete all conversations',
1434
+ body: `This will delete ${count} saved conversation(s) and all in-memory messages in this panel.`,
1435
+ buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Delete' })]
1436
+ });
1437
+ if (!result.button.accept) {
1438
+ return;
1439
+ }
1440
+ const activeSessionKey = currentNotebookSessionKeyRef.current || '';
1441
+ if (activeSessionKey) {
1442
+ clearRunMappingForSessionKey(activeSessionKey);
1443
+ }
1444
+ markDeleteAllPending();
1445
+ if (!deleteAllSessionsOnServer() && activeSessionKey) {
1446
+ appendMessage(activeSessionKey, 'system', 'Delete request could not be sent now. It will be retried when you reconnect.');
1447
+ }
1448
+ runToSessionKeyRef.current = new Map();
1449
+ activeSessionKeyByPathRef.current = new Map();
1450
+ lastActiveCellAttachmentSignatureRef.current = new Map();
1451
+ safeLocalStorageRemove(STORAGE_KEY_SESSION_THREADS);
1452
+ clearStoredSelectionPreviews();
1453
+ replaceSessions(new Map());
1454
+ clearInputForCurrentSession();
1455
+ clearPendingImages();
1456
+ }
1457
+ async function refreshNotebook(sessionKey) {
1458
+ const { path } = parseSessionKey(sessionKey);
1459
+ if (!path) {
1460
+ return;
1461
+ }
1462
+ const activeWidget = getActiveDocumentWidget(props.app, activeDocumentWidgetRef.current);
1463
+ const activePath = getSupportedDocumentPath(activeWidget);
1464
+ const isSessionStillRunning = sessionsRef.current.get(sessionKey)?.runState === 'running';
1465
+ if (isSessionStillRunning && activePath !== path) {
1466
+ pendingRefreshPathsRef.current.add(sessionKey);
1467
+ return;
1468
+ }
1469
+ const widget = findDocumentWidgetByPath(props.app, path, activeDocumentWidgetRef.current);
1470
+ if (!widget) {
1471
+ pendingRefreshPathsRef.current.add(sessionKey);
1472
+ return;
1473
+ }
1474
+ const context = getDocumentContext(widget);
1475
+ if (!context || typeof context.revert !== 'function') {
1476
+ pendingRefreshPathsRef.current.add(sessionKey);
1477
+ return;
1478
+ }
1479
+ activeDocumentWidgetRef.current = widget;
1480
+ if (context.model.dirty) {
1481
+ pendingRefreshPathsRef.current.add(sessionKey);
1482
+ appendMessage(sessionKey, 'system', 'Codex updated files, but this document has unsaved changes. Reload manually when ready.');
1483
+ return;
1484
+ }
1485
+ const viewState = captureDocumentViewState(widget);
1486
+ try {
1487
+ await context.revert();
1488
+ restoreDocumentViewState(widget, viewState);
1489
+ appendMessage(sessionKey, 'system', 'Document refreshed due to file changes.');
1490
+ pendingRefreshPathsRef.current.delete(sessionKey);
1491
+ }
1492
+ catch (err) {
1493
+ appendMessage(sessionKey, 'system', `Failed to refresh document: ${String(err)}`);
1494
+ pendingRefreshPathsRef.current.add(sessionKey);
1495
+ }
1496
+ }
1497
+ useEffect(() => {
1498
+ const updateNotebook = () => {
1499
+ const activeWidget = getActiveDocumentWidget(props.app, activeDocumentWidgetRef.current);
1500
+ if (activeWidget) {
1501
+ activeDocumentWidgetRef.current = activeWidget;
1502
+ }
1503
+ const nextIsNotebookEditor = isNotebookWidget(activeWidget);
1504
+ setCurrentDocumentIsNotebookEditor(prev => (prev === nextIsNotebookEditor ? prev : nextIsNotebookEditor));
1505
+ const path = getSupportedDocumentPath(activeWidget);
1506
+ const sessionKey = resolveSessionKey(path);
1507
+ const previousKey = currentNotebookSessionKeyRef.current;
1508
+ if (sessionKey && sessionKey === previousKey) {
1509
+ return;
1510
+ }
1511
+ if (previousKey) {
1512
+ saveCurrentInputDraft(inputRef.current);
1513
+ }
1514
+ currentNotebookPathRef.current = path;
1515
+ setCurrentNotebookPath(path);
1516
+ setCurrentNotebookSessionKey(sessionKey);
1517
+ currentNotebookSessionKeyRef.current = sessionKey;
1518
+ if (!sessionKey) {
1519
+ clearInputForCurrentSession();
1520
+ return;
1521
+ }
1522
+ restoreInput(sessionKey);
1523
+ clearPendingImages();
1524
+ setIsAtBottom(true);
1525
+ if (!path) {
1526
+ return;
1527
+ }
1528
+ const session = ensureSession(path, sessionKey);
1529
+ activeSessionKeyByPathRef.current.set(path, sessionKey);
1530
+ sendStartSession(session, path, sessionKey);
1531
+ if (pendingRefreshPathsRef.current.has(sessionKey)) {
1532
+ void refreshNotebook(sessionKey);
1533
+ return;
1534
+ }
1535
+ };
1536
+ updateNotebook();
1537
+ const shellChanged = props.app.shell.currentChanged;
1538
+ if (shellChanged) {
1539
+ shellChanged.connect(updateNotebook);
1540
+ }
1541
+ props.notebooks.currentChanged.connect(updateNotebook);
1542
+ return () => {
1543
+ if (shellChanged) {
1544
+ shellChanged.disconnect(updateNotebook);
1545
+ }
1546
+ props.notebooks.currentChanged.disconnect(updateNotebook);
1547
+ };
1548
+ }, [props.app, props.notebooks]);
1549
+ useEffect(() => {
1550
+ const onStorage = (event) => {
1551
+ if (event.key !== STORAGE_KEY_SESSION_THREADS_EVENT || !event.newValue) {
1552
+ return;
1553
+ }
1554
+ const syncEvent = coerceSessionThreadSyncEvent(event.newValue);
1555
+ if (!syncEvent) {
1556
+ return;
1557
+ }
1558
+ if (syncEvent.source === sessionThreadSyncIdRef.current) {
1559
+ return;
1560
+ }
1561
+ const notebookPath = syncEvent.notebookPath;
1562
+ const sessionKey = syncEvent.sessionKey;
1563
+ const threadId = syncEvent.threadId;
1564
+ if (!notebookPath || !sessionKey || !threadId) {
1565
+ return;
1566
+ }
1567
+ const resolvedSessionKey = resolveSessionKey(notebookPath);
1568
+ if (!resolvedSessionKey || resolvedSessionKey !== sessionKey) {
1569
+ return;
1570
+ }
1571
+ const resetSession = createThreadResetSession(notebookPath, sessionKey, threadId);
1572
+ const currentPath = currentNotebookPathRef.current;
1573
+ const existingSession = sessionsRef.current.get(sessionKey);
1574
+ if (existingSession && existingSession.threadId === threadId) {
1575
+ return;
1576
+ }
1577
+ if (existingSession?.threadId) {
1578
+ const previousDedupKey = makeActiveCellAttachmentDedupKey(sessionKey, existingSession.threadId);
1579
+ lastActiveCellAttachmentSignatureRef.current.delete(previousDedupKey);
1580
+ }
1581
+ updateSessions(prev => {
1582
+ const next = new Map(prev);
1583
+ next.set(sessionKey, resetSession);
1584
+ return next;
1585
+ });
1586
+ activeSessionKeyByPathRef.current.set(notebookPath, sessionKey);
1587
+ clearRunMappingForSessionKey(sessionKey);
1588
+ if (currentPath === notebookPath && currentNotebookSessionKeyRef.current !== sessionKey) {
1589
+ setCurrentNotebookSessionKey(sessionKey);
1590
+ }
1591
+ if (currentPath === notebookPath) {
1592
+ clearInputForCurrentSession();
1593
+ clearPendingImages();
1594
+ }
1595
+ sendStartSession(resetSession, notebookPath, sessionKey);
1596
+ };
1597
+ window.addEventListener('storage', onStorage);
1598
+ return () => {
1599
+ window.removeEventListener('storage', onStorage);
1600
+ };
1601
+ }, []);
1602
+ function onSocketOpen() {
1603
+ resetSocketMessageQueue();
1604
+ if (hasDeleteAllPending()) {
1605
+ deleteAllSessionsOnServer();
1606
+ }
1607
+ const activeWidget = getActiveDocumentWidget(props.app, activeDocumentWidgetRef.current);
1608
+ if (activeWidget) {
1609
+ activeDocumentWidgetRef.current = activeWidget;
1610
+ }
1611
+ const notebookPath = currentNotebookPathRef.current || getSupportedDocumentPath(activeWidget);
1612
+ if (!notebookPath) {
1613
+ return;
1614
+ }
1615
+ const currentSessionKey = resolveCurrentSessionKey(notebookPath);
1616
+ const session = ensureSession(notebookPath, currentSessionKey);
1617
+ sendStartSession(session, notebookPath, currentSessionKey);
1618
+ }
1619
+ function onSocketClose() {
1620
+ resetSocketMessageQueue();
1621
+ runToSessionKeyRef.current = new Map();
1622
+ setIsPlainPyRunInProgress(false);
1623
+ plainPyRunSessionKeyRef.current = '';
1624
+ }
1625
+ function processSocketMessage(rawMessage) {
1626
+ try {
1627
+ handleCodexSocketMessage(rawMessage, {
1628
+ appendMessage,
1629
+ clearDeleteAllPending,
1630
+ coerceModelCatalog: parseModelCatalog,
1631
+ coerceReasoningEffort,
1632
+ coerceRateLimitsSnapshot,
1633
+ coerceSessionHistory,
1634
+ coerceNotebookMode,
1635
+ createSession,
1636
+ deleteAllSessionsOnServer,
1637
+ getCommandPath: () => commandPath,
1638
+ getCurrentSessionKey: () => currentNotebookSessionKeyRef.current,
1639
+ getStoredSelectionPreviews: () => storedSelectionPreviewsRef.current,
1640
+ hashSelectionPreviewContent,
1641
+ hasDeleteAllPending,
1642
+ isSessionStartNotice,
1643
+ markDeleteAllPending,
1644
+ normalizeSystemText,
1645
+ notifyRunDone,
1646
+ refreshNotebook,
1647
+ resolveMessageSessionKey,
1648
+ runToSessionKeyRef,
1649
+ setCliDefaults,
1650
+ setCommandPath,
1651
+ setRateLimits,
1652
+ setSessionConversationMode,
1653
+ setSessionPairing,
1654
+ setSessionProgress,
1655
+ setSessionRunState,
1656
+ getSessionThreadId: (sessionKey) => {
1657
+ const normalizedSessionKey = (sessionKey || '').trim();
1658
+ if (!normalizedSessionKey) {
1659
+ return '';
1660
+ }
1661
+ const session = sessionsRef.current.get(normalizedSessionKey);
1662
+ return (session?.threadId || '').trim();
1663
+ },
1664
+ appendActivityItem,
1665
+ syncEffectiveSandboxFromStatus,
1666
+ updateSessions: updater => updateSessions(previous => updater(previous))
1667
+ });
1668
+ }
1669
+ catch (err) {
1670
+ const sessionKey = currentNotebookSessionKeyRef.current || '';
1671
+ if (sessionKey) {
1672
+ appendMessage(sessionKey, 'system', `Internal UI error while processing a server message: ${String(err)}`);
1673
+ }
1674
+ console.error('[Codex] onSocketMessage failed', err, rawMessage);
1675
+ }
1676
+ }
1677
+ function clearSocketMessageFlushTimer() {
1678
+ const rafId = socketMessageFlushRafRef.current;
1679
+ if (rafId !== null) {
1680
+ window.cancelAnimationFrame(rafId);
1681
+ socketMessageFlushRafRef.current = null;
1682
+ }
1683
+ const timerId = socketMessageFlushTimerRef.current;
1684
+ if (timerId === null) {
1685
+ return;
1686
+ }
1687
+ window.clearTimeout(timerId);
1688
+ socketMessageFlushTimerRef.current = null;
1689
+ }
1690
+ function flushSocketMessageQueue() {
1691
+ clearSocketMessageFlushTimer();
1692
+ const queue = socketMessageQueueRef.current;
1693
+ if (queue.length === 0) {
1694
+ return;
1695
+ }
1696
+ const batch = queue.splice(0, SOCKET_MESSAGE_BATCH_SIZE);
1697
+ for (const rawMessage of batch) {
1698
+ processSocketMessage(rawMessage);
1699
+ }
1700
+ if (queue.length > 0) {
1701
+ scheduleSocketMessageFlush();
1702
+ }
1703
+ }
1704
+ function scheduleSocketMessageFlush() {
1705
+ if (socketMessageFlushTimerRef.current !== null || socketMessageFlushRafRef.current !== null) {
1706
+ return;
1707
+ }
1708
+ if (typeof window.requestAnimationFrame === 'function') {
1709
+ socketMessageFlushRafRef.current = window.requestAnimationFrame(() => {
1710
+ socketMessageFlushRafRef.current = null;
1711
+ flushSocketMessageQueue();
1712
+ });
1713
+ return;
1714
+ }
1715
+ socketMessageFlushTimerRef.current = window.setTimeout(flushSocketMessageQueue, SOCKET_MESSAGE_FALLBACK_FLUSH_MS);
1716
+ }
1717
+ function resetSocketMessageQueue() {
1718
+ socketMessageQueueRef.current = [];
1719
+ clearSocketMessageFlushTimer();
1720
+ }
1721
+ function onSocketMessage(rawMessage) {
1722
+ const queue = socketMessageQueueRef.current;
1723
+ queue.push(rawMessage);
1724
+ if (queue.length > SOCKET_MESSAGE_MAX_QUEUE) {
1725
+ queue.splice(0, queue.length - SOCKET_MESSAGE_MAX_QUEUE);
1726
+ }
1727
+ scheduleSocketMessageFlush();
1728
+ }
1729
+ useEffect(() => {
1730
+ return () => {
1731
+ resetSocketMessageQueue();
1732
+ };
1733
+ }, []);
1734
+ useEffect(() => {
1735
+ if (!isAtBottom) {
1736
+ return;
1737
+ }
1738
+ const id = window.requestAnimationFrame(() => scrollToBottom());
1739
+ return () => window.cancelAnimationFrame(id);
1740
+ }, [isAtBottom, sessions, currentNotebookPath, socketConnected]);
1741
+ function autosizeComposerTextarea(el) {
1742
+ const textarea = el ?? composerTextareaRef.current;
1743
+ if (!textarea) {
1744
+ return;
1745
+ }
1746
+ // Reset first so it can shrink as content is removed.
1747
+ textarea.style.height = 'auto';
1748
+ const cs = window.getComputedStyle(textarea);
1749
+ // Prefer CSS-controlled min/max heights so the JS sizing stays consistent
1750
+ // with the visual design (and any future CSS tweaks).
1751
+ let minHeight = Number.parseFloat(cs.minHeight || '');
1752
+ let maxHeight = Number.parseFloat(cs.maxHeight || '');
1753
+ if (!Number.isFinite(minHeight) || minHeight <= 0) {
1754
+ const fontSize = Number.parseFloat(cs.fontSize || '13');
1755
+ let lineHeight = Number.parseFloat(cs.lineHeight || '');
1756
+ if (!Number.isFinite(lineHeight)) {
1757
+ lineHeight = fontSize * 1.35;
1758
+ }
1759
+ minHeight = lineHeight;
1760
+ }
1761
+ if (!Number.isFinite(maxHeight) || maxHeight <= 0) {
1762
+ const fontSize = Number.parseFloat(cs.fontSize || '13');
1763
+ let lineHeight = Number.parseFloat(cs.lineHeight || '');
1764
+ if (!Number.isFinite(lineHeight)) {
1765
+ lineHeight = fontSize * 1.35;
1766
+ }
1767
+ maxHeight = lineHeight * 3;
1768
+ }
1769
+ const scrollHeight = textarea.scrollHeight;
1770
+ const isEmpty = textarea.value.length === 0;
1771
+ const unclampedHeight = isEmpty ? minHeight : scrollHeight;
1772
+ const nextHeight = Math.ceil(Math.min(Math.max(unclampedHeight, minHeight), maxHeight));
1773
+ textarea.style.height = `${nextHeight}px`;
1774
+ textarea.style.overflowY = scrollHeight > maxHeight + 1 ? 'auto' : 'hidden';
1775
+ }
1776
+ useEffect(() => {
1777
+ // Defer to the next frame so layout reflects the latest value.
1778
+ const id = window.requestAnimationFrame(() => autosizeComposerTextarea());
1779
+ return () => window.cancelAnimationFrame(id);
1780
+ }, [input]);
1781
+ useEffect(() => {
1782
+ const onResize = () => autosizeComposerTextarea();
1783
+ window.addEventListener('resize', onResize);
1784
+ return () => window.removeEventListener('resize', onResize);
1785
+ }, []);
1786
+ async function startNewThread() {
1787
+ const path = currentNotebookPathRef.current || '';
1788
+ const sessionKey = currentNotebookSessionKeyRef.current || '';
1789
+ if (!path) {
1790
+ return;
1791
+ }
1792
+ if (!sessionKey) {
1793
+ return;
1794
+ }
1795
+ const existing = sessionsRef.current.get(sessionKey);
1796
+ const hasConversation = existing?.messages.some(msg => msg.kind === 'text' && (msg.role === 'user' || msg.role === 'assistant')) ?? false;
1797
+ if (hasConversation) {
1798
+ const result = await showDialog({
1799
+ title: 'Start a new thread?',
1800
+ body: 'Starting a new thread will reset the current conversation, and you will not be able to view the previous conversation in this panel.\nContinue?',
1801
+ buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'New thread' })]
1802
+ });
1803
+ if (!result.button.accept) {
1804
+ return;
1805
+ }
1806
+ }
1807
+ const newSessionBase = createThreadResetSession(path, sessionKey, createSessionEventId());
1808
+ const newSession = {
1809
+ ...newSessionBase,
1810
+ selectedModelOption: modelOption,
1811
+ selectedReasoningEffort: reasoningEffort,
1812
+ selectedSandboxMode: sandboxMode,
1813
+ };
1814
+ if (existing?.threadId) {
1815
+ const previousDedupKey = makeActiveCellAttachmentDedupKey(sessionKey, existing.threadId);
1816
+ lastActiveCellAttachmentSignatureRef.current.delete(previousDedupKey);
1817
+ }
1818
+ updateSessions(prev => {
1819
+ const next = new Map(prev);
1820
+ next.set(sessionKey, newSession);
1821
+ return next;
1822
+ });
1823
+ clearRunMappingForSessionKey(sessionKey);
1824
+ clearInputForCurrentSession();
1825
+ clearPendingImages();
1826
+ sendStartSession(newSession, path, sessionKey, { forceNewThread: true });
1827
+ emitSessionThreadEvent(sessionKey, path, newSession.threadId);
1828
+ }
1829
+ function cancelRun() {
1830
+ const sessionKey = currentNotebookSessionKeyRef.current || '';
1831
+ if (!sessionKey) {
1832
+ return;
1833
+ }
1834
+ const session = sessionKey ? sessionsRef.current.get(sessionKey) : null;
1835
+ const runId = session?.activeRunId ?? null;
1836
+ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
1837
+ appendMessage(sessionKey, 'system', 'Cancel failed: WebSocket is not connected.');
1838
+ return;
1839
+ }
1840
+ if (!runId) {
1841
+ appendMessage(sessionKey, 'system', 'Cancel not available yet (waiting for run id).');
1842
+ return;
1843
+ }
1844
+ setSessionProgress(sessionKey, 'Cancelling...');
1845
+ safeSocketSend(JSON.stringify(buildCancelMessage(runId)));
1846
+ }
1847
+ function clearPendingImages() {
1848
+ for (const image of pendingImagesRef.current) {
1849
+ URL.revokeObjectURL(image.previewUrl);
1850
+ }
1851
+ pendingImagesRef.current = [];
1852
+ setPendingImages([]);
1853
+ }
1854
+ function removePendingImage(id) {
1855
+ setPendingImages(prev => {
1856
+ const removed = prev.find(image => image.id === id);
1857
+ if (removed) {
1858
+ URL.revokeObjectURL(removed.previewUrl);
1859
+ }
1860
+ const next = prev.filter(image => image.id !== id);
1861
+ pendingImagesRef.current = next;
1862
+ return next;
1863
+ });
1864
+ }
1865
+ function onComposerPaste(event) {
1866
+ const items = event.clipboardData?.items;
1867
+ if (!items || items.length === 0) {
1868
+ return;
1869
+ }
1870
+ const found = [];
1871
+ for (let idx = 0; idx < items.length; idx += 1) {
1872
+ const item = items[idx];
1873
+ if (!item || item.kind !== 'file' || !item.type.startsWith('image/')) {
1874
+ continue;
1875
+ }
1876
+ const file = item.getAsFile();
1877
+ if (file) {
1878
+ found.push(file);
1879
+ }
1880
+ }
1881
+ if (found.length === 0) {
1882
+ return;
1883
+ }
1884
+ const existingCount = pendingImagesRef.current.length;
1885
+ if (existingCount >= MAX_IMAGE_ATTACHMENTS) {
1886
+ appendMessage(currentNotebookSessionKeyRef.current || '', 'system', `Too many images attached (max ${MAX_IMAGE_ATTACHMENTS}).`);
1887
+ return;
1888
+ }
1889
+ const remainingSlots = MAX_IMAGE_ATTACHMENTS - existingCount;
1890
+ const toAdd = [];
1891
+ let skippedLarge = 0;
1892
+ let skippedTotal = 0;
1893
+ let totalBytes = pendingImagesRef.current.reduce((sum, image) => sum + image.file.size, 0);
1894
+ for (const file of found.slice(0, remainingSlots)) {
1895
+ if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) {
1896
+ skippedLarge += 1;
1897
+ continue;
1898
+ }
1899
+ if (totalBytes + file.size > MAX_IMAGE_ATTACHMENT_TOTAL_BYTES) {
1900
+ skippedTotal += 1;
1901
+ continue;
1902
+ }
1903
+ toAdd.push({ id: crypto.randomUUID(), file, previewUrl: URL.createObjectURL(file) });
1904
+ totalBytes += file.size;
1905
+ }
1906
+ if (skippedLarge > 0) {
1907
+ appendMessage(currentNotebookSessionKeyRef.current || '', 'system', `Skipped ${skippedLarge} image(s): each must be <= ${Math.round(MAX_IMAGE_ATTACHMENT_BYTES / (1024 * 1024))}MB.`);
1908
+ }
1909
+ if (skippedTotal > 0) {
1910
+ appendMessage(currentNotebookSessionKeyRef.current || '', 'system', `Skipped ${skippedTotal} image(s): total attachments must be <= ${Math.round(MAX_IMAGE_ATTACHMENT_TOTAL_BYTES / (1024 * 1024))}MB.`);
1911
+ }
1912
+ if (toAdd.length === 0) {
1913
+ return;
1914
+ }
1915
+ setPendingImages(prev => {
1916
+ const next = [...prev, ...toAdd];
1917
+ pendingImagesRef.current = next;
1918
+ return next;
1919
+ });
1920
+ }
1921
+ async function buildQueuedImagePayloads(attachments) {
1922
+ if (attachments.length === 0) {
1923
+ return [];
1924
+ }
1925
+ try {
1926
+ const output = [];
1927
+ for (const image of attachments) {
1928
+ output.push({
1929
+ name: image.file.name || 'image',
1930
+ dataUrl: await blobToDataUrl(image.file)
1931
+ });
1932
+ }
1933
+ return output;
1934
+ }
1935
+ catch (err) {
1936
+ appendMessage(currentNotebookSessionKeyRef.current || '', 'system', `Failed to prepare image attachment: ${String(err)}`);
1937
+ return null;
1938
+ }
1939
+ }
1940
+ async function sendMessage(options) {
1941
+ const socket = wsRef.current;
1942
+ const notebookPath = (options?.forcedNotebookPath ?? currentNotebookPathRef.current) || '';
1943
+ const sessionKey = (options?.forcedSessionKey ?? currentNotebookSessionKeyRef.current) || '';
1944
+ if (!notebookPath) {
1945
+ return false;
1946
+ }
1947
+ if (!sessionKey) {
1948
+ return false;
1949
+ }
1950
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
1951
+ appendMessage(sessionKey, 'system', 'WebSocket is not connected.');
1952
+ return false;
1953
+ }
1954
+ const existing = sessionKey ? sessionsRef.current.get(sessionKey) : null;
1955
+ if (existing?.pairedOk === false) {
1956
+ appendMessage(sessionKey, 'system', existing.pairedMessage ||
1957
+ `Jupytext paired file not found. Expected: ${existing.pairedOsPath || existing.pairedPath || '<notebook>.py'}`);
1958
+ return false;
1959
+ }
1960
+ const forcedText = typeof options?.forcedText === 'string' ? options.forcedText : null;
1961
+ const trimmed = (forcedText ?? inputRef.current).trim();
1962
+ const hasImages = forcedText == null && pendingImagesRef.current.length > 0;
1963
+ if (!trimmed && !hasImages) {
1964
+ return false;
1965
+ }
1966
+ const current = sessionKey ? sessionsRef.current.get(sessionKey) : null;
1967
+ if (current?.runState === 'running' && options?.skipRunStateCheck !== true) {
1968
+ return false;
1969
+ }
1970
+ const activeWidget = findDocumentWidgetByPath(props.app, notebookPath, activeDocumentWidgetRef.current);
1971
+ if (activeWidget) {
1972
+ activeDocumentWidgetRef.current = activeWidget;
1973
+ }
1974
+ const activeWidgetPath = getSupportedDocumentPath(activeWidget);
1975
+ const activeContext = activeWidget ? getDocumentContext(activeWidget) : null;
1976
+ if (autoSaveBeforeSend &&
1977
+ activeContext &&
1978
+ typeof activeContext.save === 'function' &&
1979
+ activeWidgetPath === notebookPath &&
1980
+ activeContext.model.dirty) {
1981
+ try {
1982
+ await activeContext.save();
1983
+ }
1984
+ catch (err) {
1985
+ appendMessage(sessionKey, 'system', `Auto-save failed: ${String(err)}`);
1986
+ return false;
1987
+ }
1988
+ }
1989
+ const notebookMode = current?.notebookMode ?? inferNotebookModeFromPath(notebookPath);
1990
+ const session = ensureSession(notebookPath, sessionKey);
1991
+ const selectedContext = getSelectedContext(activeWidget, notebookMode);
1992
+ const selectedTextForContext = selectedContext?.text || '';
1993
+ let includeSelectionKey = false;
1994
+ let selection = '';
1995
+ if (includeActiveCell) {
1996
+ if (notebookMode === 'plain_py') {
1997
+ const selectedText = selectedTextForContext || getSelectedTextFromActiveCell(activeWidget) || getSelectedTextFromFileEditor(activeWidget);
1998
+ if (selectedText) {
1999
+ includeSelectionKey = true;
2000
+ selection = selectedText;
2001
+ }
2002
+ }
2003
+ else {
2004
+ includeSelectionKey = true;
2005
+ selection =
2006
+ selectedTextForContext || getActiveCellText(activeWidget) || getSelectedTextFromFileEditor(activeWidget);
2007
+ }
2008
+ }
2009
+ const includeCellOutputKey = includeActiveCell &&
2010
+ includeActiveCellOutput &&
2011
+ (notebookMode === 'ipynb' || notebookMode === 'jupytext_py');
2012
+ const cellOutputRaw = includeCellOutputKey ? getActiveCellOutput(activeWidget) : '';
2013
+ const attachmentLimit = limitActiveCellAttachmentPayload(includeSelectionKey ? selection : '', includeCellOutputKey ? cellOutputRaw : '', MAX_ACTIVE_CELL_SELECTION_CHARS, MAX_ACTIVE_CELL_OUTPUT_CHARS);
2014
+ const selectionForAttachment = includeSelectionKey ? attachmentLimit.selection : '';
2015
+ const cellOutputForAttachment = includeCellOutputKey ? attachmentLimit.cellOutput : '';
2016
+ const includeSelectionKeyAfterLimit = Boolean(selectionForAttachment);
2017
+ const includeCellOutputKeyAfterLimit = Boolean(cellOutputForAttachment);
2018
+ const messageSelectionPreview = includeSelectionKeyAfterLimit
2019
+ ? toMessageSelectionPreview(selectedContext, activeWidget, notebookMode, selectionForAttachment)
2020
+ : undefined;
2021
+ const messageCellOutputPreview = includeCellOutputKeyAfterLimit
2022
+ ? toCellOutputPreview(selectedContext, activeWidget, notebookMode, cellOutputForAttachment)
2023
+ : undefined;
2024
+ const shouldDeduplicateSelection = includeActiveCell && includeSelectionKeyAfterLimit;
2025
+ const shouldDeduplicateCellOutput = includeActiveCell && includeCellOutputKeyAfterLimit;
2026
+ const activeCellAttachmentDedupKey = makeActiveCellAttachmentDedupKey(sessionKey, session.threadId);
2027
+ const previousActiveCellSignatures = lastActiveCellAttachmentSignatureRef.current.get(activeCellAttachmentDedupKey);
2028
+ const activeCellSelectionSignature = shouldDeduplicateSelection
2029
+ ? buildActiveCellSelectionSignature({
2030
+ notebookMode,
2031
+ text: selection,
2032
+ locationLabel: messageSelectionPreview?.locationLabel
2033
+ })
2034
+ : '';
2035
+ const activeCellOutputSignature = shouldDeduplicateCellOutput
2036
+ ? buildActiveCellOutputSignature({
2037
+ notebookMode,
2038
+ text: cellOutputRaw,
2039
+ locationLabel: messageCellOutputPreview?.locationLabel
2040
+ })
2041
+ : '';
2042
+ const hasDuplicateSelectionAttachment = shouldDeduplicateSelection &&
2043
+ isDuplicateActiveCellAttachmentSignature(previousActiveCellSignatures?.selectionSignature, activeCellSelectionSignature);
2044
+ const hasDuplicateCellOutputAttachment = shouldDeduplicateCellOutput &&
2045
+ isDuplicateActiveCellAttachmentSignature(previousActiveCellSignatures?.cellOutputSignature, activeCellOutputSignature);
2046
+ const includeSelectionKeyForSend = includeSelectionKeyAfterLimit && !hasDuplicateSelectionAttachment;
2047
+ const includeCellOutputKeyForSend = includeCellOutputKeyAfterLimit && !hasDuplicateCellOutputAttachment;
2048
+ const sentAttachmentTruncation = resolveSentAttachmentTruncation({
2049
+ includeSelection: includeSelectionKeyForSend,
2050
+ includeCellOutput: includeCellOutputKeyForSend,
2051
+ selectionTruncated: attachmentLimit.selectionTruncated,
2052
+ cellOutputTruncated: attachmentLimit.cellOutputTruncated
2053
+ });
2054
+ const messageSelectionPreviewForSend = hasDuplicateSelectionAttachment ? undefined : messageSelectionPreview;
2055
+ const messageCellOutputPreviewForSend = hasDuplicateCellOutputAttachment ? undefined : messageCellOutputPreview;
2056
+ const messageContextPreview = messageSelectionPreviewForSend || messageCellOutputPreviewForSend
2057
+ ? {
2058
+ ...(messageSelectionPreviewForSend ? { selectionPreview: messageSelectionPreviewForSend } : {}),
2059
+ ...(messageCellOutputPreviewForSend ? { cellOutputPreview: messageCellOutputPreviewForSend } : {})
2060
+ }
2061
+ : undefined;
2062
+ const modelForSend = current?.selectedModelOption ?? modelOption;
2063
+ const reasoningForSend = current?.selectedReasoningEffort ?? reasoningEffort;
2064
+ const sandboxForSend = current?.selectedSandboxMode ?? sandboxMode;
2065
+ const selectedModelForSend = modelForSend === '__config__' ? (autoModel || '').trim() : modelForSend.trim();
2066
+ const selectedReasoningForSend = reasoningForSend === '__config__'
2067
+ ? coerceReasoningEffort(autoReasoningEffort || '') || resolveFallbackReasoningEffort(reasoningOptions)
2068
+ : reasoningForSend;
2069
+ if (!selectedModelForSend) {
2070
+ appendMessage(sessionKey, 'system', 'Model is not resolved yet. Wait for model defaults to load, or pick a model explicitly.');
2071
+ return false;
2072
+ }
2073
+ if (!selectedReasoningForSend) {
2074
+ appendMessage(sessionKey, 'system', 'Reasoning level is not resolved yet. Wait for defaults to load, or pick a reasoning level explicitly.');
2075
+ return false;
2076
+ }
2077
+ const content = trimmed || (hasImages ? 'Please analyze the attached image(s).' : '');
2078
+ const imagePayloads = hasImages ? await buildQueuedImagePayloads(pendingImagesRef.current) : null;
2079
+ if (hasImages && !imagePayloads) {
2080
+ return false;
2081
+ }
2082
+ let images;
2083
+ if (imagePayloads && imagePayloads.length > 0) {
2084
+ images = imagePayloads;
2085
+ }
2086
+ if (!safeSocketSend(JSON.stringify(buildSendMessage({
2087
+ sessionId: session.threadId,
2088
+ sessionContextKey: sessionKey,
2089
+ content,
2090
+ notebookPath,
2091
+ commandPath: commandPath.trim(),
2092
+ model: selectedModelForSend,
2093
+ reasoningEffort: selectedReasoningForSend,
2094
+ sandbox: sandboxForSend,
2095
+ ...(includeSelectionKeyForSend ? { selection: selectionForAttachment } : {}),
2096
+ ...(includeCellOutputKeyForSend ? { cellOutput: cellOutputForAttachment } : {}),
2097
+ ...(sentAttachmentTruncation.selectionTruncated ? { selectionTruncated: true } : {}),
2098
+ ...(sentAttachmentTruncation.cellOutputTruncated ? { cellOutputTruncated: true } : {}),
2099
+ ...(images ? { images } : {}),
2100
+ ...(messageSelectionPreviewForSend ? { uiSelectionPreview: messageSelectionPreviewForSend } : {}),
2101
+ ...(messageCellOutputPreviewForSend ? { uiCellOutputPreview: messageCellOutputPreviewForSend } : {})
2102
+ })))) {
2103
+ appendMessage(sessionKey, 'system', 'Failed to send message to Codex: WebSocket is unavailable.');
2104
+ return false;
2105
+ }
2106
+ if (shouldDeduplicateSelection || shouldDeduplicateCellOutput) {
2107
+ const nextActiveCellSignatures = { ...(previousActiveCellSignatures ?? {}) };
2108
+ if (shouldDeduplicateSelection && activeCellSelectionSignature) {
2109
+ nextActiveCellSignatures.selectionSignature = activeCellSelectionSignature;
2110
+ }
2111
+ if (shouldDeduplicateCellOutput && activeCellOutputSignature) {
2112
+ nextActiveCellSignatures.cellOutputSignature = activeCellOutputSignature;
2113
+ }
2114
+ if (nextActiveCellSignatures.selectionSignature || nextActiveCellSignatures.cellOutputSignature) {
2115
+ lastActiveCellAttachmentSignatureRef.current.set(activeCellAttachmentDedupKey, nextActiveCellSignatures);
2116
+ }
2117
+ else {
2118
+ lastActiveCellAttachmentSignatureRef.current.delete(activeCellAttachmentDedupKey);
2119
+ }
2120
+ }
2121
+ appendStoredSelectionPreviewEntry(session.threadId, content, messageContextPreview);
2122
+ const imageCount = images ? images.length : 0;
2123
+ const showReadOnlyWarning = sandboxForSend === 'read-only';
2124
+ const attachmentTruncationNotice = buildAttachmentTruncationNotice(sentAttachmentTruncation.selectionTruncated, sentAttachmentTruncation.cellOutputTruncated, MAX_ACTIVE_CELL_SELECTION_CHARS, MAX_ACTIVE_CELL_OUTPUT_CHARS);
2125
+ if (notebookMode === 'plain_py' || notebookMode === 'jupytext_py') {
2126
+ plainPyRunSessionKeyRef.current = sessionKey;
2127
+ setIsPlainPyRunInProgress(true);
2128
+ }
2129
+ updateSessions(prev => {
2130
+ const next = new Map(prev);
2131
+ const existing = next.get(sessionKey) ?? createSession('', `Session started`, { sessionKey });
2132
+ const warningEntry = showReadOnlyWarning
2133
+ ? [
2134
+ {
2135
+ kind: 'text',
2136
+ id: crypto.randomUUID(),
2137
+ role: 'system',
2138
+ text: normalizeSystemText('system', READ_ONLY_PERMISSION_WARNING)
2139
+ }
2140
+ ]
2141
+ : [];
2142
+ const truncationNoticeEntry = attachmentTruncationNotice
2143
+ ? [
2144
+ {
2145
+ kind: 'text',
2146
+ id: crypto.randomUUID(),
2147
+ role: 'system',
2148
+ text: normalizeSystemText('system', attachmentTruncationNotice)
2149
+ }
2150
+ ]
2151
+ : [];
2152
+ const updatedMessages = [
2153
+ ...existing.messages,
2154
+ ...warningEntry,
2155
+ {
2156
+ kind: 'text',
2157
+ id: crypto.randomUUID(),
2158
+ role: 'user',
2159
+ text: content,
2160
+ attachments: imageCount > 0 ? { images: imageCount } : undefined,
2161
+ selectionPreview: messageSelectionPreviewForSend,
2162
+ cellOutputPreview: messageCellOutputPreviewForSend
2163
+ },
2164
+ ...truncationNoticeEntry
2165
+ ];
2166
+ next.set(sessionKey, {
2167
+ ...existing,
2168
+ messages: trimSessionMessages(updatedMessages),
2169
+ runState: 'running',
2170
+ activeRunId: null,
2171
+ runStartedAt: Date.now(),
2172
+ progress: '',
2173
+ progressKind: ''
2174
+ });
2175
+ return next;
2176
+ });
2177
+ if (forcedText == null) {
2178
+ clearInputForCurrentSession();
2179
+ clearPendingImages();
2180
+ }
2181
+ return true;
2182
+ }
2183
+ const currentSession = currentNotebookSessionKey ? sessions.get(currentNotebookSessionKey) : null;
2184
+ const messages = currentSession?.messages ?? [];
2185
+ const progress = currentSession?.progress ?? '';
2186
+ const progressKind = currentSession?.progressKind ?? '';
2187
+ const status = socketConnected ? currentSession?.runState ?? 'ready' : 'disconnected';
2188
+ useEffect(() => {
2189
+ closeSelectionPopover();
2190
+ }, [currentNotebookSessionKey]);
2191
+ useEffect(() => {
2192
+ if (!selectionPopover) {
2193
+ return;
2194
+ }
2195
+ const exists = messages.some(entry => entry.kind === 'text' && entry.id === selectionPopover.messageId);
2196
+ if (exists) {
2197
+ return;
2198
+ }
2199
+ closeSelectionPopover();
2200
+ }, [messages, selectionPopover]);
2201
+ useEffect(() => {
2202
+ const previous = previousSessionThreadIdsRef.current;
2203
+ for (const [sessionKey, session] of sessions) {
2204
+ const nextThreadId = (session?.threadId || '').trim();
2205
+ const previousThreadId = (previous.get(sessionKey) || '').trim();
2206
+ if (previousThreadId && nextThreadId && previousThreadId !== nextThreadId) {
2207
+ migrateStoredSelectionPreviewEntries(previousThreadId, nextThreadId);
2208
+ }
2209
+ }
2210
+ const nextBySessionKey = new Map();
2211
+ for (const [sessionKey, session] of sessions) {
2212
+ const threadId = (session?.threadId || '').trim();
2213
+ if (!sessionKey || !threadId) {
2214
+ continue;
2215
+ }
2216
+ nextBySessionKey.set(sessionKey, threadId);
2217
+ }
2218
+ previousSessionThreadIdsRef.current = nextBySessionKey;
2219
+ }, [sessions]);
2220
+ const displayPath = currentNotebookPath
2221
+ ? currentNotebookPath.split('/').pop() || 'Untitled'
2222
+ : 'No notebook';
2223
+ const composerNotebookMode = currentSession?.notebookMode ?? inferNotebookModeFromPath(currentNotebookPath);
2224
+ const cellAttachmentState = resolveCellAttachmentState({
2225
+ includeActiveCell,
2226
+ includeActiveCellOutput,
2227
+ notebookMode: composerNotebookMode,
2228
+ isNotebookEditor: currentDocumentIsNotebookEditor,
2229
+ currentNotebookPath,
2230
+ pairedOk: currentSession?.pairedOk
2231
+ });
2232
+ const includeCellOutputForNextSend = cellAttachmentState.outputEnabled;
2233
+ const showCellAttachmentBadge = cellAttachmentState.showBadge;
2234
+ const cellAttachmentContentEnabled = cellAttachmentState.contentEnabled;
2235
+ const cellAttachmentOutputEnabled = cellAttachmentState.outputEnabled;
2236
+ useEffect(() => {
2237
+ if (showCellAttachmentBadge) {
2238
+ return;
2239
+ }
2240
+ setCellAttachmentPopoverOpen(false);
2241
+ clearCellAttachmentPopoverCloseTimer();
2242
+ }, [showCellAttachmentBadge]);
2243
+ useEffect(() => {
2244
+ return () => {
2245
+ clearCellAttachmentPopoverCloseTimer();
2246
+ };
2247
+ }, []);
2248
+ useEffect(() => {
2249
+ return () => {
2250
+ clearContextPopoverCloseTimer();
2251
+ };
2252
+ }, []);
2253
+ const trimmedInput = input.trim();
2254
+ const canSend = status !== 'disconnected' &&
2255
+ currentNotebookPath.length > 0 &&
2256
+ currentSession?.pairedOk !== false;
2257
+ const sendButtonMode = status === 'running' ? 'stop' : 'send';
2258
+ const runningSummary = status === 'running' ? progress || 'Working...' : '';
2259
+ const activeModelOption = currentSession?.selectedModelOption ?? modelOption;
2260
+ const activeReasoningEffort = currentSession?.selectedReasoningEffort ?? reasoningEffort;
2261
+ const activeSandboxMode = currentSession?.selectedSandboxMode ?? sandboxMode;
2262
+ const autoModelLabel = autoModel
2263
+ ? findModelLabel(autoModel, modelOptions)
2264
+ : 'Auto';
2265
+ const selectedModelLabel = activeModelOption === '__config__'
2266
+ ? autoModelLabel
2267
+ : findModelLabel(activeModelOption, modelOptions);
2268
+ const autoReasoningLabel = autoReasoningEffort
2269
+ ? findReasoningLabel(autoReasoningEffort, reasoningOptions)
2270
+ : 'Auto';
2271
+ const selectedReasoningLabel = activeReasoningEffort === '__config__'
2272
+ ? autoReasoningLabel
2273
+ : findReasoningLabel(activeReasoningEffort, reasoningOptions);
2274
+ const selectedSandboxLabel = SANDBOX_OPTIONS.find(option => option.value === activeSandboxMode)?.label ?? 'Permission';
2275
+ const notificationPermission = getBrowserNotificationPermission();
2276
+ const notificationsUnsupported = notificationPermission === 'unsupported';
2277
+ const minimumNotifyDurationLabel = notifyOnDoneMinSeconds === 0
2278
+ ? 'All completed runs'
2279
+ : `Runs taking at least ${notifyOnDoneMinSeconds} second${notifyOnDoneMinSeconds === 1 ? '' : 's'}`;
2280
+ const notificationHelpText = notificationPermission === 'unsupported'
2281
+ ? 'Browser notifications are not available in this environment.'
2282
+ : notificationPermission === 'denied'
2283
+ ? 'Notifications are blocked for this site. Allow them in browser settings.'
2284
+ : notificationPermission === 'default'
2285
+ ? 'Permission will be requested when enabling this option.'
2286
+ : `Shows a browser notification for ${minimumNotifyDurationLabel}.`;
2287
+ const hasPythonRunInProgress = Array.from(sessions.entries()).some(([sessionKey, session]) => {
2288
+ if (session.runState !== 'running') {
2289
+ return false;
2290
+ }
2291
+ const mode = session.notebookMode ?? inferNotebookModeFromPath(parseSessionKey(sessionKey).path);
2292
+ return mode === 'plain_py' || mode === 'jupytext_py';
2293
+ });
2294
+ const hasPythonRunLockRef = Boolean(plainPyRunSessionKeyRef.current);
2295
+ const canStop = status === 'running' && Boolean(currentSession?.activeRunId);
2296
+ const canSendNow = canSend && (Boolean(trimmedInput) || pendingImages.length > 0);
2297
+ const isPythonNotebookModeForLock = composerNotebookMode === 'plain_py' || composerNotebookMode === 'jupytext_py';
2298
+ const plainPythonSessionActive = isPythonNotebookModeForLock && (hasPythonRunInProgress || hasPythonRunLockRef) && sendButtonMode === 'send';
2299
+ const sendButtonDisabled = sendButtonMode === 'send'
2300
+ ? !canSendNow || plainPythonSessionActive
2301
+ : sendButtonMode === 'stop'
2302
+ ? !canStop
2303
+ : false;
2304
+ const nowMs = Date.now();
2305
+ const rateUpdatedAtMs = safeParseDateMs(rateLimits?.updatedAt ?? null);
2306
+ const rateAgeMs = rateUpdatedAtMs == null ? null : nowMs - rateUpdatedAtMs;
2307
+ const usageIsStale = rateAgeMs == null ? true : rateAgeMs > 10 * 60 * 1000;
2308
+ const sessionLeftPercent = percentLeftFromUsed(rateLimits?.primary?.usedPercent ?? null);
2309
+ const weeklyLeftPercent = percentLeftFromUsed(rateLimits?.secondary?.usedPercent ?? null);
2310
+ const usageIsUnknown = rateUpdatedAtMs == null && sessionLeftPercent == null && weeklyLeftPercent == null;
2311
+ const sessionResetsIn = formatResetsIn(rateLimits?.primary?.resetsAt ?? null, nowMs);
2312
+ const weeklyResetsIn = formatResetsIn(rateLimits?.secondary?.resetsAt ?? null, nowMs);
2313
+ const usageIsOverdue = sessionResetsIn === 'Overdue' || weeklyResetsIn === 'Overdue' || (rateAgeMs != null && rateAgeMs > 60 * 60 * 1000);
2314
+ const batteryLevel = sessionLeftPercent == null ? null : sessionLeftPercent / 100;
2315
+ const usageUpdatedAgo = rateAgeMs == null ? 'Unknown' : `${formatDurationShort(rateAgeMs)} ago`;
2316
+ const sessionWindowMinutes = rateLimits?.primary?.windowMinutes ?? null;
2317
+ const weeklyWindowMinutes = rateLimits?.secondary?.windowMinutes ?? null;
2318
+ const sessionWindowLabel = sessionWindowMinutes == null ? '' : `Window: ${Math.round(sessionWindowMinutes / 60)}h`;
2319
+ const weeklyWindowLabel = weeklyWindowMinutes == null ? '' : `Window: ${Math.round(weeklyWindowMinutes / (60 * 24))}d`;
2320
+ const contextWindowTokens = rateLimits?.contextWindow?.windowTokens ?? null;
2321
+ const contextUsedTokens = rateLimits?.contextWindow?.usedTokens ?? null;
2322
+ const contextLeftTokens = rateLimits?.contextWindow?.leftTokens ?? null;
2323
+ const contextUsedPercent = rateLimits?.contextWindow?.usedPercent ?? null;
2324
+ const contextLevel = typeof contextUsedPercent === 'number' && Number.isFinite(contextUsedPercent)
2325
+ ? clampNumber(contextUsedPercent / 100, 0, 1)
2326
+ : null;
2327
+ const contextUsedLabel = formatTokenCount(contextUsedTokens);
2328
+ const contextLeftLabel = formatTokenCount(contextLeftTokens);
2329
+ const contextWindowLabel = formatTokenCount(contextWindowTokens);
2330
+ const contextUsedPercentLabel = typeof contextUsedPercent === 'number' && Number.isFinite(contextUsedPercent)
2331
+ ? `${Math.round(clampNumber(contextUsedPercent, 0, 100))}%`
2332
+ : 'Unknown';
2333
+ const hasContextUsageSnapshot = rateLimits?.contextWindow != null;
2334
+ useEffect(() => {
2335
+ if (hasContextUsageSnapshot) {
2336
+ return;
2337
+ }
2338
+ setContextPopoverOpen(false);
2339
+ clearContextPopoverCloseTimer();
2340
+ }, [hasContextUsageSnapshot]);
2341
+ useLayoutEffect(() => {
2342
+ const target = notebookLabelRef.current;
2343
+ if (!target) {
2344
+ setIsNotebookLabelTruncated(false);
2345
+ return;
2346
+ }
2347
+ const update = () => {
2348
+ const next = target.scrollWidth - target.clientWidth > 1;
2349
+ setIsNotebookLabelTruncated(prev => (prev === next ? prev : next));
2350
+ };
2351
+ update();
2352
+ if (typeof ResizeObserver === 'undefined') {
2353
+ window.addEventListener('resize', update);
2354
+ return () => {
2355
+ window.removeEventListener('resize', update);
2356
+ };
2357
+ }
2358
+ const observer = new ResizeObserver(() => {
2359
+ update();
2360
+ });
2361
+ observer.observe(target);
2362
+ if (target.parentElement) {
2363
+ observer.observe(target.parentElement);
2364
+ }
2365
+ window.addEventListener('resize', update);
2366
+ return () => {
2367
+ observer.disconnect();
2368
+ window.removeEventListener('resize', update);
2369
+ };
2370
+ }, [currentNotebookPath]);
2371
+ return (_jsxs("div", { className: "jp-CodexChat", children: [_jsxs("div", { className: "jp-CodexChat-header", children: [_jsxs("div", { className: "jp-CodexChat-header-top", children: [_jsxs("div", { className: "jp-CodexChat-header-left", children: [_jsx(StatusPill, { status: status }), _jsx("span", { className: "jp-CodexChat-notebookWrap", "data-full-name": isNotebookLabelTruncated ? displayPath : undefined, children: _jsx("span", { className: "jp-CodexChat-notebook", ref: notebookLabelRef, title: isNotebookLabelTruncated ? displayPath : undefined, children: displayPath }) })] }), _jsxs("div", { className: "jp-CodexChat-header-actions", children: [_jsx("button", { type: "button", onClick: () => void startNewThread(), className: "jp-CodexHeaderBtn", disabled: !currentNotebookPath || status === 'running', "aria-label": "New thread", title: "New thread", children: _jsx(PlusIcon, { width: 16, height: 16 }) }), _jsx("div", { className: "jp-CodexMenuWrap", ref: usageMenuWrapRef, children: _jsx("button", { type: "button", className: `jp-CodexHeaderBtn jp-CodexHeaderBtn-icon jp-CodexUsageBtn${usagePopoverOpen ? ' is-active is-open' : ''}${usageIsStale ? ' is-stale' : ''}${usageIsOverdue ? ' is-overdue' : ''}`, ref: usageBtnRef, onClick: () => toggleUsagePopover(), "aria-label": sessionLeftPercent == null ? 'Codex usage' : `Codex usage: ${sessionLeftPercent}% left`, "aria-haspopup": "dialog", "aria-expanded": usagePopoverOpen, title: sessionLeftPercent == null
2372
+ ? 'Codex usage: unknown'
2373
+ : `Codex usage: ${sessionLeftPercent}% left (resets in ${sessionResetsIn})`, children: _jsx(BatteryIcon, { level: batteryLevel, width: 16, height: 16 }) }) }), _jsxs(PortalMenu, { open: usagePopoverOpen, anchorRef: usageBtnRef, popoverRef: usagePopoverRef, className: "jp-CodexUsagePopover", ariaLabel: "Codex usage", role: "dialog", align: "right", children: [(usageIsOverdue || usageIsStale) && (_jsxs("div", { className: `jp-CodexUsageNotice${usageIsOverdue ? ' is-overdue' : usageIsStale ? ' is-stale' : ''}`, children: [_jsx("div", { className: "jp-CodexUsageNoticeTitle", children: usageIsUnknown
2374
+ ? 'Usage unavailable'
2375
+ : usageIsOverdue
2376
+ ? 'Overdue usage snapshot'
2377
+ : 'Stale usage snapshot' }), _jsx("div", { className: "jp-CodexUsageNoticeBody", children: usageIsUnknown ? 'Run Codex once to fetch usage limits.' : 'Run Codex again to refresh these numbers.' })] })), _jsxs("div", { className: "jp-CodexUsageSection", children: [_jsxs("div", { className: "jp-CodexUsageSectionTop", children: [_jsx("div", { className: "jp-CodexUsageSectionTitle", children: "Session" }), _jsxs("div", { className: "jp-CodexUsageSectionReset", children: ["Resets in ", sessionResetsIn] })] }), _jsx("div", { className: "jp-CodexUsageBar", children: _jsx("div", { className: `jp-CodexUsageBarFill${usageIsStale ? ' is-stale' : ''}`, style: { width: `${sessionLeftPercent ?? 0}%` } }) }), _jsxs("div", { className: "jp-CodexUsageMeta", children: [_jsx("div", { className: "jp-CodexUsageMetaLeft", children: sessionLeftPercent == null ? '--% left' : `${sessionLeftPercent}% left` }), _jsx("div", { className: "jp-CodexUsageMetaRight", children: sessionWindowLabel })] })] }), _jsx("div", { className: "jp-CodexMenuDivider", role: "separator" }), _jsxs("div", { className: "jp-CodexUsageSection", children: [_jsxs("div", { className: "jp-CodexUsageSectionTop", children: [_jsx("div", { className: "jp-CodexUsageSectionTitle", children: "Weekly" }), _jsxs("div", { className: "jp-CodexUsageSectionReset", children: ["Resets in ", weeklyResetsIn] })] }), _jsx("div", { className: "jp-CodexUsageBar", children: _jsx("div", { className: `jp-CodexUsageBarFill${usageIsStale ? ' is-stale' : ''}`, style: { width: `${weeklyLeftPercent ?? 0}%` } }) }), _jsxs("div", { className: "jp-CodexUsageMeta", children: [_jsx("div", { className: "jp-CodexUsageMetaLeft", children: weeklyLeftPercent == null ? '--% left' : `${weeklyLeftPercent}% left` }), _jsx("div", { className: "jp-CodexUsageMetaRight", children: weeklyWindowLabel })] })] }), _jsxs("div", { className: "jp-CodexUsageFooter", children: ["Last updated: ", usageUpdatedAgo] })] }), _jsx("button", { type: "button", onClick: () => {
2378
+ setSettingsOpen(open => !open);
2379
+ setModelMenuOpen(false);
2380
+ setReasoningMenuOpen(false);
2381
+ setUsagePopoverOpen(false);
2382
+ setPermissionMenuOpen(false);
2383
+ setContextPopoverOpen(false);
2384
+ }, className: `jp-CodexHeaderBtn jp-CodexHeaderBtn-icon${settingsOpen ? ' is-active' : ''}`, "aria-label": "Settings", "aria-expanded": settingsOpen, title: "Settings", children: _jsx(GearIcon, { width: 16, height: 16 }) })] })] }), currentSession?.pairedOk === false && (_jsxs("div", { className: "jp-CodexPairingNotice", role: "status", "aria-live": "polite", children: [_jsx("div", { className: "jp-CodexPairingNotice-title", children: "Jupytext pairing required" }), _jsx("div", { className: "jp-CodexPairingNotice-body", children: currentSession.pairedMessage ||
2385
+ 'This notebook must be paired (.ipynb ↔ .py) via Jupytext to enable running.' })] }))] }), _jsx("div", { className: "jp-CodexChat-body", children: _jsxs("div", { className: "jp-CodexChat-messages", ref: scrollRef, onScroll: onScrollMessages, children: [status === 'disconnected' && !isReconnecting && (_jsxs("div", { className: "jp-CodexChat-message jp-CodexChat-system jp-CodexChat-reconnectNotice", children: [_jsx("div", { className: "jp-CodexChat-role", children: "system" }), _jsx("div", { className: "jp-CodexChat-text", children: "Codex connection was lost. Reconnect to continue." }), _jsx("button", { type: "button", className: "jp-CodexReconnectBtn", onClick: () => reconnectSocket(), disabled: isReconnecting, "aria-label": isReconnecting ? 'Codex reconnecting' : 'Reconnect to Codex', title: isReconnecting ? 'Attempting to reconnect...' : 'Reconnect to Codex', children: isReconnecting ? 'Connecting...' : 'Reconnect' })] })), messages.length === 0 && (_jsxs("div", { className: "jp-CodexChat-message jp-CodexChat-system", children: [_jsx("div", { className: "jp-CodexChat-role", children: "system" }), _jsx("div", { className: "jp-CodexChat-text", children: "Select a notebook, then start a conversation." })] })), messages.map(entry => {
2386
+ if (entry.kind === 'text') {
2387
+ const systemVariant = entry.role === 'system'
2388
+ ? isSessionStartNotice(entry.text, entry.sessionResolution)
2389
+ ? ' is-success'
2390
+ : ''
2391
+ : '';
2392
+ const imageCount = entry.attachments?.images ?? 0;
2393
+ const selectionPreview = entry.selectionPreview;
2394
+ const cellOutputPreview = entry.cellOutputPreview;
2395
+ const hasSelectionPreview = entry.role === 'user' &&
2396
+ Boolean(selectionPreview?.locationLabel && selectionPreview?.previewText);
2397
+ const hasCellOutputPreview = entry.role === 'user' &&
2398
+ Boolean(cellOutputPreview?.locationLabel && cellOutputPreview?.previewText);
2399
+ const hasContextPreview = hasSelectionPreview || hasCellOutputPreview;
2400
+ const isSelectionPreviewOpen = hasContextPreview && selectionPopover?.messageId === entry.id;
2401
+ const messageClassName = `jp-CodexChat-message jp-CodexChat-${entry.role}${systemVariant}${hasContextPreview ? ' has-selection-preview' : ''}${isSelectionPreviewOpen ? ' is-selection-open' : ''}`;
2402
+ return (_jsxs("div", { className: messageClassName, children: [_jsx("div", { className: "jp-CodexChat-role", children: entry.role }), _jsx(MessageText, { text: entry.text, canCopyCode: entry.role === 'assistant' }), imageCount > 0 && (_jsx("div", { className: "jp-CodexChat-attachments", "aria-label": `${imageCount} image attachment(s)`, title: `${imageCount} image attachment(s)`, children: _jsxs("span", { className: "jp-CodexChat-attachmentPill", children: [_jsx(ImageIcon, { width: 14, height: 14 }), _jsx("span", { className: "jp-CodexChat-attachmentCount", children: imageCount })] }) })), hasContextPreview && (_jsx("button", { type: "button", className: `jp-CodexChat-selectionToggle${isSelectionPreviewOpen ? ' is-open' : ''}`, onClick: event => toggleSelectionPopover(entry.id, {
2403
+ ...(selectionPreview ? { selectionPreview } : {}),
2404
+ ...(cellOutputPreview ? { cellOutputPreview } : {})
2405
+ }, event), "aria-label": isSelectionPreviewOpen ? 'Hide message context' : 'Show message context', children: _jsx(PlusIcon, { width: "1em", height: "1em" }) }))] }, entry.id));
2406
+ }
2407
+ if (entry.kind === 'run-divider') {
2408
+ return (_jsx("div", { className: "jp-CodexRunDivider", role: "separator", "aria-label": "Run duration", children: _jsxs("span", { className: "jp-CodexRunDividerLabel", children: ["Worked for ", formatRunDuration(entry.elapsedMs)] }) }, entry.id));
2409
+ }
2410
+ const item = entry.item;
2411
+ const trimmedDetail = (item.detail || '').trim();
2412
+ const isExpandable = Boolean(trimmedDetail);
2413
+ const activityClassName = `jp-CodexChat-message jp-CodexChat-activity${isExpandable ? ' is-expandable' : ''} is-${item.category}${item.phase ? ` is-${item.phase}` : ''}`;
2414
+ const icon = item.category === 'file' ? (_jsx(FileIcon, { width: 14, height: 14 })) : item.phase === 'completed' ? (_jsx(CheckIcon, { width: 14, height: 14 })) : item.phase === 'started' ? (_jsx("span", { className: "jp-CodexActivityDot" })) : (_jsx("span", { className: "jp-CodexActivityDot is-idle" }));
2415
+ const summaryContent = (_jsxs(_Fragment, { children: [_jsx("span", { className: "jp-CodexActivityLineIcon", "aria-hidden": "true", children: icon }), _jsx("span", { className: "jp-CodexActivityLineText", children: _jsx("span", { className: "jp-CodexActivityLineTitle", children: item.title }) })] }));
2416
+ if (isExpandable) {
2417
+ return (_jsxs("details", { className: activityClassName, role: "status", "aria-live": "polite", children: [_jsx("summary", { className: "jp-CodexActivitySummary", children: summaryContent }), _jsx("div", { className: "jp-CodexActivityBody", children: _jsx("pre", { className: "jp-CodexActivityCode", children: _jsx("code", { children: trimmedDetail }) }) })] }, entry.id));
2418
+ }
2419
+ return (_jsx("div", { className: activityClassName, role: "status", "aria-live": "polite", children: _jsx("div", { className: "jp-CodexActivitySummary jp-CodexActivitySummaryStatic", children: summaryContent }) }, entry.id));
2420
+ }), status === 'running' && (_jsx("div", { className: `jp-CodexChat-loading${progressKind === 'reasoning' ? ' is-reasoning' : ''}`, "aria-label": progressKind === 'reasoning' ? 'Reasoning' : 'Running', children: _jsxs("div", { className: "jp-CodexChat-loading-dots", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }) })), _jsx("div", { ref: endRef })] }) }), _jsx(PortalMenu, { open: Boolean(selectionPopover), anchorRef: selectionPopoverAnchorRef, popoverRef: selectionPopoverRef, className: "jp-CodexChat-selectionPopover", ariaLabel: "Message context", constrainHeightToViewport: true, viewportMargin: 20, role: "dialog", align: "right", children: selectionPopover && (_jsxs("div", { className: "jp-CodexChat-selectionCard", role: "note", "aria-label": "Message context", children: [selectionPopover.preview.selectionPreview && (_jsxs("div", { className: "jp-CodexChat-contextSection", children: [_jsx("div", { className: "jp-CodexChat-selectionMeta", children: selectionPopover.preview.selectionPreview.locationLabel }), _jsx(SelectionPreviewCode, { code: selectionPopover.preview.selectionPreview.previewText })] })), selectionPopover.preview.cellOutputPreview && (_jsxs("div", { className: "jp-CodexChat-contextSection", children: [_jsx("div", { className: "jp-CodexChat-selectionMeta", children: selectionPopover.preview.cellOutputPreview.locationLabel }), _jsx(SelectionPreviewCode, { code: selectionPopover.preview.cellOutputPreview.previewText })] }))] })) }), _jsx(PortalMenu, { open: cellAttachmentPopoverOpen && showCellAttachmentBadge, anchorRef: cellAttachmentAnchorRef, popoverRef: cellAttachmentPopoverRef, className: "jp-CodexCellAttachmentPopoverMenu", ariaLabel: "Cell attachment details", role: "dialog", align: "left", onMouseEnter: openCellAttachmentPopover, onMouseLeave: scheduleCloseCellAttachmentPopover, children: _jsxs("div", { className: "jp-CodexCellAttachmentPopoverCard", role: "note", "aria-label": "Cell attachment details", children: [_jsx("div", { className: "jp-CodexCellAttachmentPopoverTitle", children: "Attach On Next Send" }), _jsxs("div", { className: "jp-CodexCellAttachmentPopoverRow", children: [_jsx("span", { children: "Current cell content" }), _jsx("span", { className: `jp-CodexCellAttachmentDot ${cellAttachmentContentEnabled ? 'is-on' : 'is-off'}`, "aria-label": cellAttachmentContentEnabled ? 'Attached' : 'Not attached', title: cellAttachmentContentEnabled ? 'Attached' : 'Not attached' })] }), _jsxs("div", { className: "jp-CodexCellAttachmentPopoverRow", children: [_jsx("span", { children: "Current cell output" }), _jsx("span", { className: `jp-CodexCellAttachmentDot ${cellAttachmentOutputEnabled ? 'is-on' : 'is-off'}`, "aria-label": cellAttachmentOutputEnabled ? 'Attached' : 'Not attached', title: cellAttachmentOutputEnabled ? 'Attached' : 'Not attached' })] })] }) }), _jsxs("div", { className: "jp-CodexChat-input", children: [_jsx("div", { className: `jp-CodexJumpBar${isAtBottom ? '' : ' is-visible'}`, children: _jsx("button", { type: "button", className: "jp-CodexJumpToLatestBtn", onClick: scrollToBottom, "aria-label": "Jump to latest", "aria-hidden": isAtBottom, tabIndex: isAtBottom ? -1 : 0, title: "Jump to latest", children: _jsx(ArrowDownIcon, { width: 20, height: 20 }) }) }), _jsxs("div", { className: "jp-CodexComposer", children: [_jsx("div", { className: `jp-CodexCellAttachmentWrap jp-CodexComposer-cellAttachmentWrap${showCellAttachmentBadge ? ' is-visible' : ''}`, ref: cellAttachmentAnchorRef, "aria-hidden": !showCellAttachmentBadge, onMouseEnter: openCellAttachmentPopover, onMouseLeave: scheduleCloseCellAttachmentPopover, onFocusCapture: openCellAttachmentPopover, onBlurCapture: handleCellAttachmentBlur, children: _jsx("div", { role: "group", "aria-label": "Active-cell attachment", children: _jsxs("button", { type: "button", className: `jp-CodexComposer-cellAttachment${cellAttachmentContentEnabled ? '' : ' is-off'}`, onClick: () => setIncludeActiveCell(value => !value), "aria-pressed": cellAttachmentContentEnabled, "aria-label": cellAttachmentContentEnabled ? 'Disable active-cell attachment' : 'Enable active-cell attachment', title: cellAttachmentContentEnabled ? 'Disable active-cell attachment' : 'Enable active-cell attachment', disabled: !showCellAttachmentBadge, tabIndex: showCellAttachmentBadge ? 0 : -1, children: [_jsx(CellAttachmentIcon, { active: cellAttachmentContentEnabled, width: 15, height: 15 }), _jsx("span", { className: "jp-CodexComposer-cellAttachmentLabel", children: "Cell Attatch" })] }) }) }), _jsx("textarea", { ref: composerTextareaRef, value: input, onChange: e => {
2421
+ updateInput(e.currentTarget.value);
2422
+ // Resize using the current target so typing feels immediate.
2423
+ window.requestAnimationFrame(() => autosizeComposerTextarea(e.currentTarget));
2424
+ }, onPaste: onComposerPaste, placeholder: currentSession?.pairedOk === false
2425
+ ? 'Disabled: missing Jupytext paired file (.py)'
2426
+ : currentNotebookPath
2427
+ ? 'Ask Codex...'
2428
+ : 'Select a notebook first', rows: 1, onKeyDown: e => {
2429
+ // Avoid interfering with IME composition (Korean/Japanese/etc.)
2430
+ const native = e.nativeEvent;
2431
+ if (native.isComposing || native.keyCode === 229) {
2432
+ return;
2433
+ }
2434
+ if (e.key !== 'Enter' || e.shiftKey) {
2435
+ return;
2436
+ }
2437
+ if (status === 'running') {
2438
+ e.preventDefault();
2439
+ return;
2440
+ }
2441
+ if (canSendNow) {
2442
+ e.preventDefault();
2443
+ void sendMessage();
2444
+ }
2445
+ } }), pendingImages.length > 0 && (_jsx("div", { className: "jp-CodexComposer-attachments", role: "group", "aria-label": "Attachments", children: pendingImages.map(image => (_jsxs("div", { className: "jp-CodexComposer-attachment", children: [_jsx("img", { src: image.previewUrl, alt: image.file.name || 'Pasted image' }), _jsx("button", { type: "button", className: "jp-CodexComposer-attachmentRemove", onClick: () => removePendingImage(image.id), "aria-label": "Remove image", title: "Remove image", children: _jsx(XIcon, { width: 14, height: 14 }) })] }, image.id))) })), _jsxs("div", { className: "jp-CodexComposer-toolbar", children: [_jsxs("div", { className: "jp-CodexComposer-toolbarLeft", children: [_jsx("div", { className: "jp-CodexMenuWrap jp-CodexModelWrap", ref: modelMenuWrapRef, children: _jsx("button", { type: "button", ref: modelBtnRef, className: `jp-CodexModelBtn ${modelMenuOpen ? 'is-open' : ''}`, onClick: () => {
2446
+ setModelMenuOpen(open => !open);
2447
+ setReasoningMenuOpen(false);
2448
+ setUsagePopoverOpen(false);
2449
+ setPermissionMenuOpen(false);
2450
+ setContextPopoverOpen(false);
2451
+ }, disabled: status === 'running', "aria-label": `Model: ${selectedModelLabel}`, "aria-haspopup": "menu", "aria-expanded": modelMenuOpen, title: `Model: ${selectedModelLabel}`, children: _jsx("span", { className: "jp-CodexModelBtn-label", children: selectedModelLabel }) }) }), _jsxs(PortalMenu, { open: modelMenuOpen, anchorRef: modelBtnRef, popoverRef: modelPopoverRef, role: "menu", ariaLabel: "Model", align: "left", children: [modelOptions.length === 0 && (_jsx("div", { className: "jp-CodexMenuItem", children: "No models available" })), modelOptions.map(option => {
2452
+ const inferred = activeModelOption === '__config__' && autoModel && modelOptions.some(option => option.value === autoModel)
2453
+ ? autoModel
2454
+ : activeModelOption;
2455
+ const isActive = inferred === option.value;
2456
+ return (_jsxs("button", { type: "button", className: `jp-CodexMenuItem ${isActive ? 'is-active' : ''}`, onClick: () => {
2457
+ setCurrentSessionModelOption(option.value);
2458
+ setModelMenuOpen(false);
2459
+ }, children: [_jsx("span", { className: "jp-CodexMenuItemLabel", children: option.label }), isActive && (_jsx(CheckIcon, { className: "jp-CodexMenuCheck", width: 16, height: 16 }))] }, option.value));
2460
+ })] }), _jsx("div", { className: "jp-CodexMenuWrap", ref: reasoningMenuWrapRef, children: _jsx("button", { type: "button", ref: reasoningBtnRef, className: `jp-CodexIconBtn ${reasoningMenuOpen ? 'is-open' : ''}`, onClick: () => {
2461
+ setReasoningMenuOpen(open => !open);
2462
+ setModelMenuOpen(false);
2463
+ setUsagePopoverOpen(false);
2464
+ setPermissionMenuOpen(false);
2465
+ setContextPopoverOpen(false);
2466
+ }, disabled: status === 'running', "aria-label": `Reasoning: ${selectedReasoningLabel}`, "aria-haspopup": "menu", "aria-expanded": reasoningMenuOpen, title: `Reasoning: ${selectedReasoningLabel}`, children: _jsx(ReasoningEffortIcon, { isConfig: activeReasoningEffort === '__config__' && !autoReasoningEffort, activeBars: getReasoningEffortBars((activeReasoningEffort === '__config__' && autoReasoningEffort
2467
+ ? autoReasoningEffort
2468
+ : activeReasoningEffort), reasoningOptions), width: 17, height: 17 }) }) }), _jsxs(PortalMenu, { open: reasoningMenuOpen, anchorRef: reasoningBtnRef, popoverRef: reasoningPopoverRef, role: "menu", ariaLabel: "Reasoning", align: "left", children: [reasoningOptions.length === 0 && (_jsx("div", { className: "jp-CodexMenuItem", children: "No reasoning options" })), reasoningOptions.map(option => {
2469
+ const inferred = activeReasoningEffort === '__config__' && autoReasoningEffort ? autoReasoningEffort : activeReasoningEffort;
2470
+ const isActive = inferred === option.value;
2471
+ return (_jsxs("button", { type: "button", className: `jp-CodexMenuItem ${isActive ? 'is-active' : ''}`, onClick: () => {
2472
+ setCurrentSessionReasoningEffort(option.value);
2473
+ setReasoningMenuOpen(false);
2474
+ }, children: [_jsx("span", { className: "jp-CodexMenuItemLabel", children: option.label }), isActive && (_jsx(CheckIcon, { className: "jp-CodexMenuCheck", width: 16, height: 16 }))] }, option.value));
2475
+ })] }), _jsx("div", { className: "jp-CodexMenuWrap", ref: permissionMenuWrapRef, children: _jsx("button", { type: "button", className: `jp-CodexIconBtn jp-CodexPermissionBtn${permissionMenuOpen ? ' is-open' : ''}${activeSandboxMode === 'danger-full-access' ? ' is-danger' : ''}${activeSandboxMode === 'read-only' ? ' is-warning' : ''}`, ref: permissionBtnRef, onClick: () => {
2476
+ setPermissionMenuOpen(open => !open);
2477
+ setModelMenuOpen(false);
2478
+ setReasoningMenuOpen(false);
2479
+ setUsagePopoverOpen(false);
2480
+ setContextPopoverOpen(false);
2481
+ }, disabled: status === 'running', "aria-label": `Permission: ${selectedSandboxLabel}`, "aria-haspopup": "menu", "aria-expanded": permissionMenuOpen, title: `Permission: ${selectedSandboxLabel}`, children: _jsx(ShieldIcon, { width: 17, height: 17 }) }) }), _jsx(PortalMenu, { open: permissionMenuOpen, anchorRef: permissionBtnRef, popoverRef: permissionPopoverRef, role: "menu", ariaLabel: "Permissions", align: "right", children: SANDBOX_OPTIONS.map(option => (_jsxs("button", { type: "button", className: `jp-CodexMenuItem ${activeSandboxMode === option.value ? 'is-active' : ''}`, onClick: () => {
2482
+ setCurrentSessionSandboxMode(option.value);
2483
+ setPermissionMenuOpen(false);
2484
+ }, children: [_jsx("span", { className: "jp-CodexMenuItemLabel", children: option.label }), activeSandboxMode === option.value && _jsx(CheckIcon, { className: "jp-CodexMenuCheck", width: 16, height: 16 })] }, option.value))) }), hasContextUsageSnapshot && (_jsxs("div", { className: "jp-CodexContextWrap", ref: contextMenuWrapRef, onMouseEnter: openContextPopover, onMouseLeave: scheduleCloseContextPopover, onFocusCapture: openContextPopover, onBlurCapture: handleContextPopoverBlur, children: [_jsx("button", { type: "button", className: `jp-CodexIconBtn jp-CodexContextBtn${usageIsStale ? ' is-stale' : ''}`, ref: contextBtnRef, "aria-label": contextUsedTokens == null || contextLeftTokens == null
2485
+ ? 'Context window usage unavailable'
2486
+ : `Context window: used ${contextUsedLabel} tokens, left ${contextLeftLabel} tokens`, title: contextUsedTokens == null || contextLeftTokens == null
2487
+ ? 'Context window usage unavailable'
2488
+ : `Used ${contextUsedLabel} / left ${contextLeftLabel}`, children: _jsx(ContextWindowIcon, { level: contextLevel, width: 20, height: 20 }) }), _jsxs(PortalMenu, { open: contextPopoverOpen, anchorRef: contextBtnRef, popoverRef: contextPopoverRef, className: "jp-CodexContextPopover", role: "tooltip", ariaLabel: "Context window", align: "right", onMouseEnter: openContextPopover, onMouseLeave: scheduleCloseContextPopover, children: [_jsx("div", { className: "jp-CodexContextPopoverTitle", children: "Context window" }), _jsxs("div", { className: "jp-CodexContextPopoverRow", children: [_jsx("span", { children: "Used" }), _jsx("strong", { children: contextUsedLabel })] }), _jsxs("div", { className: "jp-CodexContextPopoverRow", children: [_jsx("span", { children: "Left" }), _jsx("strong", { children: contextLeftLabel })] }), _jsx("div", { className: "jp-CodexContextPopoverMeta", children: contextWindowTokens == null
2489
+ ? 'Window size unavailable'
2490
+ : `Window: ${contextWindowLabel} tokens (${contextUsedPercentLabel} used)` })] })] }))] }), _jsx("div", { className: "jp-CodexComposer-toolbarRight", children: _jsx("button", { type: "button", className: `jp-CodexSendBtn${sendButtonMode === 'stop' ? ' is-stop' : ''}`, onClick: () => {
2491
+ if (sendButtonMode === 'stop') {
2492
+ cancelRun();
2493
+ return;
2494
+ }
2495
+ void sendMessage();
2496
+ }, disabled: sendButtonDisabled, "aria-label": sendButtonMode === 'stop' ? 'Stop run' : 'Send', title: sendButtonMode === 'stop'
2497
+ ? currentSession?.activeRunId
2498
+ ? `runId: ${currentSession.activeRunId}`
2499
+ : 'Waiting for run id...'
2500
+ : status === 'disconnected'
2501
+ ? 'Connecting...'
2502
+ : 'Send', children: sendButtonMode === 'stop' ? (_jsx(StopIcon, { width: 18, height: 18 })) : (_jsx(ArrowUpIcon, { width: 18, height: 18 })) }) })] })] })] }), settingsOpen && (_jsxs("div", { className: "jp-CodexSettingsOverlay", role: "dialog", "aria-modal": "true", "aria-label": "Settings", children: [_jsx("button", { type: "button", className: "jp-CodexSettingsBackdrop", onClick: () => setSettingsOpen(false), "aria-label": "Dismiss settings", title: "Dismiss settings" }), _jsxs("div", { className: "jp-CodexSettingsPanel", onClick: e => e.stopPropagation(), children: [_jsxs("div", { className: "jp-CodexSettingsPanel-top", children: [_jsx("div", { className: "jp-CodexSettingsPanel-title", children: "Settings" }), _jsx("button", { type: "button", className: "jp-CodexHeaderBtn jp-CodexHeaderBtn-icon", onClick: () => setSettingsOpen(false), "aria-label": "Close settings", title: "Close", children: _jsx(XIcon, { width: 16, height: 16 }) })] }), _jsxs("div", { className: "jp-CodexSettingsPanel-sections", children: [_jsxs("section", { className: "jp-CodexSettingsSection", "aria-label": "General settings", children: [_jsx("div", { className: "jp-CodexSettingsSection-title", children: "General" }), _jsxs("label", { className: "jp-CodexSettingsField", children: [_jsx("span", { className: "jp-CodexSettingsField-label", children: "Codex command path" }), _jsx("input", { type: "text", className: "jp-CodexChat-model-input", placeholder: "codex", value: commandPath, disabled: status === 'running', onChange: e => setCommandPath(e.currentTarget.value.trimStart()), title: "Leave empty to use PATH lookup." }), _jsx("span", { className: "jp-CodexSettingsField-help", children: "Leave empty to use PATH lookup." })] })] }), _jsxs("section", { className: "jp-CodexSettingsSection", "aria-label": "Message options", children: [_jsx("div", { className: "jp-CodexSettingsSection-title", children: "Message Options" }), _jsxs("div", { className: "jp-CodexSettingsOptions", children: [_jsxs("label", { className: "jp-CodexChat-toggle", children: [_jsx("input", { type: "checkbox", checked: autoSaveBeforeSend, onChange: e => setAutoSaveBeforeSend(e.currentTarget.checked), disabled: status === 'running' }), "Auto-save before send"] }), _jsxs("label", { className: "jp-CodexChat-toggle", children: [_jsx("input", { type: "checkbox", checked: includeActiveCell, onChange: e => setIncludeActiveCell(e.currentTarget.checked), disabled: status === 'running' }), "Include active cell"] }), _jsxs("label", { className: "jp-CodexChat-toggle", children: [_jsx("input", { type: "checkbox", checked: includeActiveCellOutput, onChange: e => setIncludeActiveCellOutput(e.currentTarget.checked), disabled: status === 'running' || !includeActiveCell }), "Include active cell output"] })] })] }), _jsxs("section", { className: "jp-CodexSettingsSection", "aria-label": "Notification options", children: [_jsx("div", { className: "jp-CodexSettingsSection-title", children: "NOTIFICATION" }), _jsxs("div", { className: "jp-CodexSettingsOptions", children: [_jsxs("label", { className: "jp-CodexChat-toggle", children: [_jsx("input", { type: "checkbox", checked: notifyOnDone, onChange: e => void updateNotifyOnDone(e.currentTarget.checked), disabled: status === 'running' || notificationsUnsupported }), "Notify when run finishes"] }), _jsxs("label", { className: "jp-CodexSettingsField", children: [_jsx("span", { className: "jp-CodexSettingsField-label", children: "Minimum runtime (seconds)" }), _jsx("input", { type: "number", min: "0", step: "1", className: "jp-CodexChat-model-input", value: notifyOnDoneMinSeconds, onChange: e => setNotifyOnDoneMinSeconds(Number.isFinite(Number(e.currentTarget.value)) ? Math.max(0, Math.floor(Number(e.currentTarget.value))) : 0), disabled: notificationsUnsupported }), _jsx("span", { className: "jp-CodexSettingsField-help", children: "0 means notify for every finished run. Enter only when you want delayed notifications." })] }), _jsx("span", { className: "jp-CodexSettingsField-help", children: notificationHelpText })] })] }), _jsxs("section", { className: "jp-CodexSettingsSection jp-CodexSettingsSection-danger", "aria-label": "Danger zone", children: [_jsx("div", { className: "jp-CodexSettingsSection-title jp-CodexSettingsSection-title-danger", children: "Danger Zone" }), _jsx("div", { className: "jp-CodexSettingsPanel-stats", children: _jsxs("span", { className: "jp-CodexSettingsPanel-stat", children: ["Saved conversations: ", storedThreadCount] }) }), _jsx("div", { className: "jp-CodexSettingsDanger", children: _jsx("button", { type: "button", className: "jp-CodexBtn jp-CodexBtn-xs jp-CodexBtn-danger", onClick: () => void clearAllSessions(), disabled: status === 'running' || (storedThreadCount === 0 && sessions.size === 0), title: status === 'running'
2503
+ ? 'Cannot delete while a run is in progress.'
2504
+ : 'Delete all saved conversations', children: "Delete all" }) })] })] })] })] }))] }));
2505
+ }
2506
+ //# sourceMappingURL=codexChat.js.map