jupyterlab-codex-sidebar 0.1.5 → 0.1.7

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 (41) hide show
  1. package/.jupyterlab-playwright.log +43 -0
  2. package/README.md +19 -3
  3. package/jupyterlab_codex/labextension/package.json +2 -2
  4. package/jupyterlab_codex/labextension/static/737.58bcf09100c9bc7bd90d.js +1 -0
  5. package/jupyterlab_codex/labextension/static/{remoteEntry.c1e865f207776f7f24ff.js → remoteEntry.34af8a0df422b4d029c3.js} +1 -1
  6. package/jupyterlab_codex/sessions.py +1 -1
  7. package/lib/codexChat.js +148 -52
  8. package/lib/codexChat.js.map +1 -1
  9. package/lib/codexChatAttachmentLimit.d.ts +12 -2
  10. package/lib/codexChatAttachmentLimit.js +43 -30
  11. package/lib/codexChatAttachmentLimit.js.map +1 -1
  12. package/lib/codexChatAttachmentState.d.ts +15 -0
  13. package/lib/codexChatAttachmentState.js +16 -0
  14. package/lib/codexChatAttachmentState.js.map +1 -0
  15. package/lib/codexChatDocumentUtils.d.ts +4 -1
  16. package/lib/codexChatDocumentUtils.js +71 -19
  17. package/lib/codexChatDocumentUtils.js.map +1 -1
  18. package/lib/codexChatPrimitives.d.ts +4 -1
  19. package/lib/codexChatPrimitives.js +4 -0
  20. package/lib/codexChatPrimitives.js.map +1 -1
  21. package/package.json +1 -1
  22. package/playwright.config.cjs +4 -1
  23. package/pyproject.toml +1 -1
  24. package/src/codexChat.tsx +234 -75
  25. package/src/codexChatAttachmentLimit.ts +59 -33
  26. package/src/codexChatAttachmentState.ts +37 -0
  27. package/src/codexChatDocumentUtils.ts +89 -21
  28. package/src/codexChatPrimitives.tsx +25 -1
  29. package/style/index.css +96 -40
  30. package/test-results/.last-run.json +4 -0
  31. package/test.py +0 -0
  32. package/tests/e2e/cell-output-error-tail.spec.js +165 -0
  33. package/tests/e2e/codex-ui-test-helpers.js +138 -0
  34. package/tests/e2e/fixtures/notebooks/error-output-tail.ipynb +58 -0
  35. package/tests/e2e/fixtures/notebooks/error-output-tail.py +19 -0
  36. package/tests/e2e/mock-codex-cli-prompt-echo.py +92 -0
  37. package/tests/unit/codexChatAttachmentLimit.spec.ts +33 -8
  38. package/tests/unit/codexChatAttachmentState.spec.ts +71 -0
  39. package/tests/unit/codexChatDocumentUtils.spec.ts +78 -0
  40. package/tsconfig.tsbuildinfo +1 -1
  41. package/jupyterlab_codex/labextension/static/855.d20f6158cd81bb4c9056.js +0 -1
package/src/codexChat.tsx CHANGED
@@ -73,11 +73,13 @@ import {
73
73
  getSelectedTextFromActiveCell,
74
74
  getSelectedTextFromFileEditor,
75
75
  getSupportedDocumentPath,
76
+ isNotebookWidget,
76
77
  normalizeSelectionPreviewText,
77
78
  restoreDocumentViewState,
78
79
  toCellOutputPreview,
79
- toFallbackSelectionPreview,
80
+ toMessageSelectionPreview,
80
81
  } from './codexChatDocumentUtils';
82
+ import { resolveCellAttachmentState } from './codexChatAttachmentState';
81
83
  import {
82
84
  buildActiveCellOutputSignature,
83
85
  buildActiveCellSelectionSignature,
@@ -86,13 +88,15 @@ import {
86
88
  } from './codexChatAttachmentDedup';
87
89
  import {
88
90
  buildAttachmentTruncationNotice,
89
- limitActiveCellAttachmentPayload
91
+ limitActiveCellAttachmentPayload,
92
+ resolveSentAttachmentTruncation
90
93
  } from './codexChatAttachmentLimit';
91
94
  import {
92
95
  ArrowDownIcon,
93
96
  ArrowUpIcon,
97
+ BatteryIcon,
98
+ CellAttachmentIcon,
94
99
  CheckIcon,
95
- ChipIcon,
96
100
  ContextWindowIcon,
97
101
  FileIcon,
98
102
  GaugeIcon,
@@ -103,8 +107,7 @@ import {
103
107
  ReasoningEffortIcon,
104
108
  ShieldIcon,
105
109
  StopIcon,
106
- XIcon,
107
- BatteryIcon
110
+ XIcon
108
111
  } from './codexChatPrimitives';
109
112
  import {
110
113
  hasStoredValue,
@@ -306,7 +309,8 @@ const SELECTION_PREVIEWS_STORAGE_KEY = 'jupyterlab-codex:selection-previews';
306
309
  const MAX_IMAGE_ATTACHMENTS = 4;
307
310
  const MAX_IMAGE_ATTACHMENT_BYTES = 4 * 1024 * 1024; // Avoid huge WebSocket payloads.
308
311
  const MAX_IMAGE_ATTACHMENT_TOTAL_BYTES = 6 * 1024 * 1024;
309
- const MAX_ACTIVE_CELL_ATTACHMENT_TOTAL_CHARS = 4000;
312
+ const MAX_ACTIVE_CELL_SELECTION_CHARS = 4000;
313
+ const MAX_ACTIVE_CELL_OUTPUT_CHARS = 20000;
310
314
  const MAX_STORED_SELECTION_PREVIEW_THREADS = 80;
311
315
  const MAX_STORED_SELECTION_PREVIEW_MESSAGES_PER_THREAD = 10;
312
316
  const MAX_SESSION_MESSAGES = 100;
@@ -755,7 +759,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
755
759
  const [includeActiveCellOutput, setIncludeActiveCellOutput] = useState<boolean>(() =>
756
760
  readStoredIncludeActiveCellOutput()
757
761
  );
758
- const [excludeCellAttachmentForNextSend, setExcludeCellAttachmentForNextSend] = useState<boolean>(false);
762
+ const [currentDocumentIsNotebookEditor, setCurrentDocumentIsNotebookEditor] = useState(false);
759
763
  const [notifyOnDone, setNotifyOnDone] = useState<boolean>(() => readStoredNotifyOnDone());
760
764
  const [notifyOnDoneMinSeconds, setNotifyOnDoneMinSeconds] = useState<number>(() => readStoredNotifyOnDoneMinSeconds());
761
765
  const [settingsOpen, setSettingsOpen] = useState<boolean>(() => readStoredSettingsOpen());
@@ -779,7 +783,9 @@ function CodexChat(props: CodexChatProps): JSX.Element {
779
783
  const [reasoningMenuOpen, setReasoningMenuOpen] = useState(false);
780
784
  const [usagePopoverOpen, setUsagePopoverOpen] = useState(false);
781
785
  const [permissionMenuOpen, setPermissionMenuOpen] = useState(false);
786
+ const [contextPopoverOpen, setContextPopoverOpen] = useState(false);
782
787
  const [isPlainPyRunInProgress, setIsPlainPyRunInProgress] = useState<boolean>(false);
788
+ const [cellAttachmentPopoverOpen, setCellAttachmentPopoverOpen] = useState(false);
783
789
  const [selectionPopover, setSelectionPopover] = useState<{
784
790
  messageId: string;
785
791
  preview: MessageContextPreview;
@@ -807,6 +813,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
807
813
  const reasoningMenuWrapRef = useRef<HTMLDivElement | null>(null);
808
814
  const usageMenuWrapRef = useRef<HTMLDivElement | null>(null);
809
815
  const permissionMenuWrapRef = useRef<HTMLDivElement | null>(null);
816
+ const contextMenuWrapRef = useRef<HTMLDivElement | null>(null);
810
817
  const modelBtnRef = useRef<HTMLButtonElement>(null);
811
818
  const modelPopoverRef = useRef<HTMLDivElement>(null);
812
819
  const reasoningBtnRef = useRef<HTMLButtonElement>(null);
@@ -815,7 +822,13 @@ function CodexChat(props: CodexChatProps): JSX.Element {
815
822
  const usagePopoverRef = useRef<HTMLDivElement>(null);
816
823
  const permissionBtnRef = useRef<HTMLButtonElement>(null);
817
824
  const permissionPopoverRef = useRef<HTMLDivElement>(null);
825
+ const contextBtnRef = useRef<HTMLButtonElement>(null);
826
+ const contextPopoverRef = useRef<HTMLDivElement>(null);
818
827
  const plainPyRunSessionKeyRef = useRef<string>('');
828
+ const cellAttachmentAnchorRef = useRef<HTMLDivElement | null>(null);
829
+ const cellAttachmentPopoverRef = useRef<HTMLDivElement>(null);
830
+ const cellAttachmentPopoverCloseTimerRef = useRef<number | null>(null);
831
+ const contextPopoverCloseTimerRef = useRef<number | null>(null);
819
832
  const selectionPopoverAnchorRef = useRef<HTMLElement | null>(null);
820
833
  const selectionPopoverRef = useRef<HTMLDivElement>(null);
821
834
  const notebookLabelRef = useRef<HTMLSpanElement | null>(null);
@@ -991,13 +1004,6 @@ function CodexChat(props: CodexChatProps): JSX.Element {
991
1004
  persistIncludeActiveCellOutput(includeActiveCellOutput);
992
1005
  }, [includeActiveCellOutput]);
993
1006
 
994
- useEffect(() => {
995
- // Reset one-time exclusion when the base setting is turned off.
996
- if (!includeActiveCell && excludeCellAttachmentForNextSend) {
997
- setExcludeCellAttachmentForNextSend(false);
998
- }
999
- }, [includeActiveCell, excludeCellAttachmentForNextSend]);
1000
-
1001
1007
  useEffect(() => {
1002
1008
  persistCommandPath(commandPath);
1003
1009
  }, [commandPath]);
@@ -1026,7 +1032,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
1026
1032
  }, [notifyOnDoneMinSeconds]);
1027
1033
 
1028
1034
  useEffect(() => {
1029
- if (!modelMenuOpen && !reasoningMenuOpen && !usagePopoverOpen && !permissionMenuOpen) {
1035
+ if (!modelMenuOpen && !reasoningMenuOpen && !usagePopoverOpen && !permissionMenuOpen && !contextPopoverOpen) {
1030
1036
  return;
1031
1037
  }
1032
1038
 
@@ -1040,19 +1046,23 @@ function CodexChat(props: CodexChatProps): JSX.Element {
1040
1046
  const inReasoning = reasoningMenuWrapRef.current?.contains(target) ?? false;
1041
1047
  const inUsage = usageMenuWrapRef.current?.contains(target) ?? false;
1042
1048
  const inPermission = permissionMenuWrapRef.current?.contains(target) ?? false;
1049
+ const inContext = contextMenuWrapRef.current?.contains(target) ?? false;
1043
1050
  const inModelPopover = modelPopoverRef.current?.contains(target) ?? false;
1044
1051
  const inReasoningPopover = reasoningPopoverRef.current?.contains(target) ?? false;
1045
1052
  const inUsagePopover = usagePopoverRef.current?.contains(target) ?? false;
1046
1053
  const inPermissionPopover = permissionPopoverRef.current?.contains(target) ?? false;
1054
+ const inContextPopover = contextPopoverRef.current?.contains(target) ?? false;
1047
1055
  if (
1048
1056
  inModel ||
1049
1057
  inReasoning ||
1050
1058
  inUsage ||
1051
1059
  inPermission ||
1060
+ inContext ||
1052
1061
  inModelPopover ||
1053
1062
  inReasoningPopover ||
1054
1063
  inUsagePopover ||
1055
- inPermissionPopover
1064
+ inPermissionPopover ||
1065
+ inContextPopover
1056
1066
  ) {
1057
1067
  return;
1058
1068
  }
@@ -1061,6 +1071,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
1061
1071
  setReasoningMenuOpen(false);
1062
1072
  setUsagePopoverOpen(false);
1063
1073
  setPermissionMenuOpen(false);
1074
+ setContextPopoverOpen(false);
1064
1075
  };
1065
1076
 
1066
1077
  const onKeyDown = (event: KeyboardEvent) => {
@@ -1072,6 +1083,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
1072
1083
  setReasoningMenuOpen(false);
1073
1084
  setUsagePopoverOpen(false);
1074
1085
  setPermissionMenuOpen(false);
1086
+ setContextPopoverOpen(false);
1075
1087
  };
1076
1088
 
1077
1089
  window.addEventListener('pointerdown', onPointerDown, true);
@@ -1080,7 +1092,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
1080
1092
  window.removeEventListener('pointerdown', onPointerDown, true);
1081
1093
  window.removeEventListener('keydown', onKeyDown);
1082
1094
  };
1083
- }, [modelMenuOpen, reasoningMenuOpen, usagePopoverOpen, permissionMenuOpen]);
1095
+ }, [modelMenuOpen, reasoningMenuOpen, usagePopoverOpen, permissionMenuOpen, contextPopoverOpen]);
1084
1096
 
1085
1097
  useEffect(() => {
1086
1098
  if (!selectionPopover) {
@@ -1125,6 +1137,74 @@ function CodexChat(props: CodexChatProps): JSX.Element {
1125
1137
  selectionPopoverAnchorRef.current = null;
1126
1138
  }
1127
1139
 
1140
+ function clearCellAttachmentPopoverCloseTimer(): void {
1141
+ if (cellAttachmentPopoverCloseTimerRef.current !== null) {
1142
+ window.clearTimeout(cellAttachmentPopoverCloseTimerRef.current);
1143
+ cellAttachmentPopoverCloseTimerRef.current = null;
1144
+ }
1145
+ }
1146
+
1147
+ function clearContextPopoverCloseTimer(): void {
1148
+ if (contextPopoverCloseTimerRef.current !== null) {
1149
+ window.clearTimeout(contextPopoverCloseTimerRef.current);
1150
+ contextPopoverCloseTimerRef.current = null;
1151
+ }
1152
+ }
1153
+
1154
+ function openCellAttachmentPopover(): void {
1155
+ clearCellAttachmentPopoverCloseTimer();
1156
+ if (!showCellAttachmentBadge) {
1157
+ setCellAttachmentPopoverOpen(false);
1158
+ return;
1159
+ }
1160
+ setCellAttachmentPopoverOpen(true);
1161
+ }
1162
+
1163
+ function scheduleCloseCellAttachmentPopover(): void {
1164
+ clearCellAttachmentPopoverCloseTimer();
1165
+ cellAttachmentPopoverCloseTimerRef.current = window.setTimeout(() => {
1166
+ setCellAttachmentPopoverOpen(false);
1167
+ cellAttachmentPopoverCloseTimerRef.current = null;
1168
+ }, 90);
1169
+ }
1170
+
1171
+ function openContextPopover(): void {
1172
+ clearContextPopoverCloseTimer();
1173
+ if (!hasContextUsageSnapshot) {
1174
+ setContextPopoverOpen(false);
1175
+ return;
1176
+ }
1177
+ setContextPopoverOpen(true);
1178
+ }
1179
+
1180
+ function scheduleCloseContextPopover(): void {
1181
+ clearContextPopoverCloseTimer();
1182
+ contextPopoverCloseTimerRef.current = window.setTimeout(() => {
1183
+ setContextPopoverOpen(false);
1184
+ contextPopoverCloseTimerRef.current = null;
1185
+ }, 90);
1186
+ }
1187
+
1188
+ function handleContextPopoverBlur(event: React.FocusEvent<HTMLDivElement>): void {
1189
+ const nextFocused = event.relatedTarget as Node | null;
1190
+ const inAnchor = nextFocused ? contextMenuWrapRef.current?.contains(nextFocused) ?? false : false;
1191
+ const inPopover = nextFocused ? contextPopoverRef.current?.contains(nextFocused) ?? false : false;
1192
+ if (inAnchor || inPopover) {
1193
+ return;
1194
+ }
1195
+ scheduleCloseContextPopover();
1196
+ }
1197
+
1198
+ function handleCellAttachmentBlur(event: React.FocusEvent<HTMLDivElement>): void {
1199
+ const nextFocused = event.relatedTarget as Node | null;
1200
+ const inAnchor = nextFocused ? cellAttachmentAnchorRef.current?.contains(nextFocused) ?? false : false;
1201
+ const inPopover = nextFocused ? cellAttachmentPopoverRef.current?.contains(nextFocused) ?? false : false;
1202
+ if (inAnchor || inPopover) {
1203
+ return;
1204
+ }
1205
+ scheduleCloseCellAttachmentPopover();
1206
+ }
1207
+
1128
1208
  function toggleSelectionPopover(
1129
1209
  messageId: string,
1130
1210
  preview: MessageContextPreview,
@@ -1352,6 +1432,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
1352
1432
  setModelMenuOpen(false);
1353
1433
  setReasoningMenuOpen(false);
1354
1434
  setPermissionMenuOpen(false);
1435
+ setContextPopoverOpen(false);
1355
1436
  }
1356
1437
 
1357
1438
  async function updateNotifyOnDone(enabled: boolean): Promise<void> {
@@ -1929,6 +2010,8 @@ function CodexChat(props: CodexChatProps): JSX.Element {
1929
2010
  if (activeWidget) {
1930
2011
  activeDocumentWidgetRef.current = activeWidget;
1931
2012
  }
2013
+ const nextIsNotebookEditor = isNotebookWidget(activeWidget);
2014
+ setCurrentDocumentIsNotebookEditor(prev => (prev === nextIsNotebookEditor ? prev : nextIsNotebookEditor));
1932
2015
  const path = getSupportedDocumentPath(activeWidget);
1933
2016
  const sessionKey = resolveSessionKey(path);
1934
2017
  const previousKey = currentNotebookSessionKeyRef.current;
@@ -1980,26 +2063,6 @@ function CodexChat(props: CodexChatProps): JSX.Element {
1980
2063
  };
1981
2064
  }, [props.app, props.notebooks]);
1982
2065
 
1983
- useEffect(() => {
1984
- const onActiveCellChanged = (_tracker: INotebookTracker, _cell: unknown) => {
1985
- if (!includeActiveCell || !excludeCellAttachmentForNextSend) {
1986
- return;
1987
- }
1988
-
1989
- const currentNotebookWidget = props.notebooks.currentWidget as DocumentWidgetLike | null;
1990
- const currentNotebookWidgetPath = getSupportedDocumentPath(currentNotebookWidget);
1991
- if (!currentNotebookWidgetPath || currentNotebookWidgetPath !== currentNotebookPathRef.current) {
1992
- return;
1993
- }
1994
- setExcludeCellAttachmentForNextSend(false);
1995
- };
1996
-
1997
- props.notebooks.activeCellChanged.connect(onActiveCellChanged);
1998
- return () => {
1999
- props.notebooks.activeCellChanged.disconnect(onActiveCellChanged);
2000
- };
2001
- }, [props.notebooks, includeActiveCell, excludeCellAttachmentForNextSend]);
2002
-
2003
2066
  useEffect(() => {
2004
2067
  const onStorage = (event: StorageEvent) => {
2005
2068
  if (event.key !== STORAGE_KEY_SESSION_THREADS_EVENT || !event.newValue) {
@@ -2533,8 +2596,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
2533
2596
  const selectedTextForContext = selectedContext?.text || '';
2534
2597
  let includeSelectionKey = false;
2535
2598
  let selection = '';
2536
- const includeActiveCellForNextSend = includeActiveCell && !excludeCellAttachmentForNextSend;
2537
- if (includeActiveCellForNextSend) {
2599
+ if (includeActiveCell) {
2538
2600
  if (notebookMode === 'plain_py') {
2539
2601
  const selectedText =
2540
2602
  selectedTextForContext || getSelectedTextFromActiveCell(activeWidget) || getSelectedTextFromFileEditor(activeWidget);
@@ -2549,12 +2611,15 @@ function CodexChat(props: CodexChatProps): JSX.Element {
2549
2611
  }
2550
2612
  }
2551
2613
  const includeCellOutputKey =
2552
- includeActiveCellForNextSend && includeActiveCellOutput && notebookMode === 'ipynb';
2614
+ includeActiveCell &&
2615
+ includeActiveCellOutput &&
2616
+ (notebookMode === 'ipynb' || notebookMode === 'jupytext_py');
2553
2617
  const cellOutputRaw = includeCellOutputKey ? getActiveCellOutput(activeWidget) : '';
2554
2618
  const attachmentLimit = limitActiveCellAttachmentPayload(
2555
2619
  includeSelectionKey ? selection : '',
2556
2620
  includeCellOutputKey ? cellOutputRaw : '',
2557
- MAX_ACTIVE_CELL_ATTACHMENT_TOTAL_CHARS
2621
+ MAX_ACTIVE_CELL_SELECTION_CHARS,
2622
+ MAX_ACTIVE_CELL_OUTPUT_CHARS
2558
2623
  );
2559
2624
  const selectionForAttachment = includeSelectionKey ? attachmentLimit.selection : '';
2560
2625
  const cellOutputForAttachment = includeCellOutputKey ? attachmentLimit.cellOutput : '';
@@ -2562,15 +2627,15 @@ function CodexChat(props: CodexChatProps): JSX.Element {
2562
2627
  const includeCellOutputKeyAfterLimit = Boolean(cellOutputForAttachment);
2563
2628
  const messageSelectionPreview =
2564
2629
  includeSelectionKeyAfterLimit
2565
- ? toFallbackSelectionPreview(activeWidget, notebookMode, selectionForAttachment)
2630
+ ? toMessageSelectionPreview(selectedContext, activeWidget, notebookMode, selectionForAttachment)
2566
2631
  : undefined;
2567
2632
  const messageCellOutputPreview =
2568
2633
  includeCellOutputKeyAfterLimit
2569
2634
  ? toCellOutputPreview(selectedContext, activeWidget, notebookMode, cellOutputForAttachment)
2570
2635
  : undefined;
2571
- const shouldDeduplicateSelection = includeActiveCellForNextSend && includeSelectionKeyAfterLimit;
2636
+ const shouldDeduplicateSelection = includeActiveCell && includeSelectionKeyAfterLimit;
2572
2637
  const shouldDeduplicateCellOutput =
2573
- includeActiveCellForNextSend && includeCellOutputKeyAfterLimit;
2638
+ includeActiveCell && includeCellOutputKeyAfterLimit;
2574
2639
  const activeCellAttachmentDedupKey = makeActiveCellAttachmentDedupKey(sessionKey, session.threadId);
2575
2640
  const previousActiveCellSignatures =
2576
2641
  lastActiveCellAttachmentSignatureRef.current.get(activeCellAttachmentDedupKey);
@@ -2602,6 +2667,12 @@ function CodexChat(props: CodexChatProps): JSX.Element {
2602
2667
  );
2603
2668
  const includeSelectionKeyForSend = includeSelectionKeyAfterLimit && !hasDuplicateSelectionAttachment;
2604
2669
  const includeCellOutputKeyForSend = includeCellOutputKeyAfterLimit && !hasDuplicateCellOutputAttachment;
2670
+ const sentAttachmentTruncation = resolveSentAttachmentTruncation({
2671
+ includeSelection: includeSelectionKeyForSend,
2672
+ includeCellOutput: includeCellOutputKeyForSend,
2673
+ selectionTruncated: attachmentLimit.selectionTruncated,
2674
+ cellOutputTruncated: attachmentLimit.cellOutputTruncated
2675
+ });
2605
2676
  const messageSelectionPreviewForSend = hasDuplicateSelectionAttachment ? undefined : messageSelectionPreview;
2606
2677
  const messageCellOutputPreviewForSend = hasDuplicateCellOutputAttachment ? undefined : messageCellOutputPreview;
2607
2678
  const messageContextPreview: MessageContextPreview | undefined =
@@ -2661,8 +2732,8 @@ function CodexChat(props: CodexChatProps): JSX.Element {
2661
2732
  sandbox: sandboxForSend,
2662
2733
  ...(includeSelectionKeyForSend ? { selection: selectionForAttachment } : {}),
2663
2734
  ...(includeCellOutputKeyForSend ? { cellOutput: cellOutputForAttachment } : {}),
2664
- ...(attachmentLimit.selectionTruncated ? { selectionTruncated: true } : {}),
2665
- ...(attachmentLimit.cellOutputTruncated ? { cellOutputTruncated: true } : {}),
2735
+ ...(sentAttachmentTruncation.selectionTruncated ? { selectionTruncated: true } : {}),
2736
+ ...(sentAttachmentTruncation.cellOutputTruncated ? { cellOutputTruncated: true } : {}),
2666
2737
  ...(images ? { images } : {}),
2667
2738
  ...(messageSelectionPreviewForSend ? { uiSelectionPreview: messageSelectionPreviewForSend } : {}),
2668
2739
  ...(messageCellOutputPreviewForSend ? { uiCellOutputPreview: messageCellOutputPreviewForSend } : {})
@@ -2693,9 +2764,10 @@ function CodexChat(props: CodexChatProps): JSX.Element {
2693
2764
  const imageCount = images ? images.length : 0;
2694
2765
  const showReadOnlyWarning = sandboxForSend === 'read-only';
2695
2766
  const attachmentTruncationNotice = buildAttachmentTruncationNotice(
2696
- attachmentLimit.selectionTruncated,
2697
- attachmentLimit.cellOutputTruncated,
2698
- MAX_ACTIVE_CELL_ATTACHMENT_TOTAL_CHARS
2767
+ sentAttachmentTruncation.selectionTruncated,
2768
+ sentAttachmentTruncation.cellOutputTruncated,
2769
+ MAX_ACTIVE_CELL_SELECTION_CHARS,
2770
+ MAX_ACTIVE_CELL_OUTPUT_CHARS
2699
2771
  );
2700
2772
  if (notebookMode === 'plain_py' || notebookMode === 'jupytext_py') {
2701
2773
  plainPyRunSessionKeyRef.current = sessionKey;
@@ -2801,15 +2873,40 @@ function CodexChat(props: CodexChatProps): JSX.Element {
2801
2873
  const displayPath = currentNotebookPath
2802
2874
  ? currentNotebookPath.split('/').pop() || 'Untitled'
2803
2875
  : 'No notebook';
2804
- const includeActiveCellForNextSend = includeActiveCell && !excludeCellAttachmentForNextSend;
2805
2876
  const composerNotebookMode = currentSession?.notebookMode ?? inferNotebookModeFromPath(currentNotebookPath);
2806
- const includeCellOutputForNextSend =
2807
- includeActiveCellForNextSend && includeActiveCellOutput && composerNotebookMode === 'ipynb';
2808
- const showCellAttachmentBadge =
2809
- includeActiveCellForNextSend &&
2810
- composerNotebookMode !== 'plain_py' &&
2811
- currentNotebookPath.length > 0 &&
2812
- currentSession?.pairedOk !== false;
2877
+ const cellAttachmentState = resolveCellAttachmentState({
2878
+ includeActiveCell,
2879
+ includeActiveCellOutput,
2880
+ notebookMode: composerNotebookMode,
2881
+ isNotebookEditor: currentDocumentIsNotebookEditor,
2882
+ currentNotebookPath,
2883
+ pairedOk: currentSession?.pairedOk
2884
+ });
2885
+ const includeCellOutputForNextSend = cellAttachmentState.outputEnabled;
2886
+ const showCellAttachmentBadge = cellAttachmentState.showBadge;
2887
+ const cellAttachmentContentEnabled = cellAttachmentState.contentEnabled;
2888
+ const cellAttachmentOutputEnabled = cellAttachmentState.outputEnabled;
2889
+
2890
+ useEffect(() => {
2891
+ if (showCellAttachmentBadge) {
2892
+ return;
2893
+ }
2894
+ setCellAttachmentPopoverOpen(false);
2895
+ clearCellAttachmentPopoverCloseTimer();
2896
+ }, [showCellAttachmentBadge]);
2897
+
2898
+ useEffect(() => {
2899
+ return () => {
2900
+ clearCellAttachmentPopoverCloseTimer();
2901
+ };
2902
+ }, []);
2903
+
2904
+ useEffect(() => {
2905
+ return () => {
2906
+ clearContextPopoverCloseTimer();
2907
+ };
2908
+ }, []);
2909
+
2813
2910
  const trimmedInput = input.trim();
2814
2911
  const canSend =
2815
2912
  status !== 'disconnected' &&
@@ -2903,6 +3000,14 @@ function CodexChat(props: CodexChatProps): JSX.Element {
2903
3000
  : 'Unknown';
2904
3001
  const hasContextUsageSnapshot = rateLimits?.contextWindow != null;
2905
3002
 
3003
+ useEffect(() => {
3004
+ if (hasContextUsageSnapshot) {
3005
+ return;
3006
+ }
3007
+ setContextPopoverOpen(false);
3008
+ clearContextPopoverCloseTimer();
3009
+ }, [hasContextUsageSnapshot]);
3010
+
2906
3011
  useLayoutEffect(() => {
2907
3012
  const target = notebookLabelRef.current;
2908
3013
  if (!target) {
@@ -3059,6 +3164,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
3059
3164
  setReasoningMenuOpen(false);
3060
3165
  setUsagePopoverOpen(false);
3061
3166
  setPermissionMenuOpen(false);
3167
+ setContextPopoverOpen(false);
3062
3168
  }}
3063
3169
  className={`jp-CodexHeaderBtn jp-CodexHeaderBtn-icon${settingsOpen ? ' is-active' : ''}`}
3064
3170
  aria-label="Settings"
@@ -3068,7 +3174,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
3068
3174
  <GearIcon width={16} height={16} />
3069
3175
  </button>
3070
3176
  </div>
3071
- </div>
3177
+ </div>
3072
3178
 
3073
3179
  {currentSession?.pairedOk === false && (
3074
3180
  <div className="jp-CodexPairingNotice" role="status" aria-live="polite">
@@ -3281,6 +3387,38 @@ function CodexChat(props: CodexChatProps): JSX.Element {
3281
3387
  )}
3282
3388
  </PortalMenu>
3283
3389
 
3390
+ <PortalMenu
3391
+ open={cellAttachmentPopoverOpen && showCellAttachmentBadge}
3392
+ anchorRef={cellAttachmentAnchorRef as React.RefObject<HTMLElement>}
3393
+ popoverRef={cellAttachmentPopoverRef}
3394
+ className="jp-CodexCellAttachmentPopoverMenu"
3395
+ ariaLabel="Cell attachment details"
3396
+ role="dialog"
3397
+ align="left"
3398
+ onMouseEnter={openCellAttachmentPopover}
3399
+ onMouseLeave={scheduleCloseCellAttachmentPopover}
3400
+ >
3401
+ <div className="jp-CodexCellAttachmentPopoverCard" role="note" aria-label="Cell attachment details">
3402
+ <div className="jp-CodexCellAttachmentPopoverTitle">Attach On Next Send</div>
3403
+ <div className="jp-CodexCellAttachmentPopoverRow">
3404
+ <span>Current cell content</span>
3405
+ <span
3406
+ className={`jp-CodexCellAttachmentDot ${cellAttachmentContentEnabled ? 'is-on' : 'is-off'}`}
3407
+ aria-label={cellAttachmentContentEnabled ? 'Attached' : 'Not attached'}
3408
+ title={cellAttachmentContentEnabled ? 'Attached' : 'Not attached'}
3409
+ />
3410
+ </div>
3411
+ <div className="jp-CodexCellAttachmentPopoverRow">
3412
+ <span>Current cell output</span>
3413
+ <span
3414
+ className={`jp-CodexCellAttachmentDot ${cellAttachmentOutputEnabled ? 'is-on' : 'is-off'}`}
3415
+ aria-label={cellAttachmentOutputEnabled ? 'Attached' : 'Not attached'}
3416
+ title={cellAttachmentOutputEnabled ? 'Attached' : 'Not attached'}
3417
+ />
3418
+ </div>
3419
+ </div>
3420
+ </PortalMenu>
3421
+
3284
3422
  <div className="jp-CodexChat-input">
3285
3423
  <div className={`jp-CodexJumpBar${isAtBottom ? '' : ' is-visible'}`}>
3286
3424
  <button
@@ -3297,30 +3435,30 @@ function CodexChat(props: CodexChatProps): JSX.Element {
3297
3435
  </div>
3298
3436
  <div className="jp-CodexComposer">
3299
3437
  <div
3300
- className={`jp-CodexComposer-cellAttachmentWrap${showCellAttachmentBadge ? ' is-visible' : ''}`}
3438
+ className={`jp-CodexCellAttachmentWrap jp-CodexComposer-cellAttachmentWrap${showCellAttachmentBadge ? ' is-visible' : ''}`}
3439
+ ref={cellAttachmentAnchorRef}
3301
3440
  aria-hidden={!showCellAttachmentBadge}
3441
+ onMouseEnter={openCellAttachmentPopover}
3442
+ onMouseLeave={scheduleCloseCellAttachmentPopover}
3443
+ onFocusCapture={openCellAttachmentPopover}
3444
+ onBlurCapture={handleCellAttachmentBlur}
3302
3445
  >
3303
3446
  <div
3304
- className="jp-CodexComposer-cellAttachment"
3305
3447
  role="group"
3306
- aria-label="Pending active-cell attachment"
3307
- title={
3308
- includeCellOutputForNextSend
3309
- ? 'Active cell and output will be attached on next send.'
3310
- : 'Active cell will be attached on next send.'
3311
- }
3448
+ aria-label="Active-cell attachment"
3312
3449
  >
3313
- <span className="jp-CodexComposer-cellAttachmentLabel">Cell Attached</span>
3314
3450
  <button
3315
3451
  type="button"
3316
- className="jp-CodexComposer-cellAttachmentRemove"
3317
- onClick={() => setExcludeCellAttachmentForNextSend(true)}
3318
- aria-label="Do not attach cell on next send"
3319
- title="Do not attach cell on next send"
3452
+ className={`jp-CodexComposer-cellAttachment${cellAttachmentContentEnabled ? '' : ' is-off'}`}
3453
+ onClick={() => setIncludeActiveCell(value => !value)}
3454
+ aria-pressed={cellAttachmentContentEnabled}
3455
+ aria-label={cellAttachmentContentEnabled ? 'Disable active-cell attachment' : 'Enable active-cell attachment'}
3456
+ title={cellAttachmentContentEnabled ? 'Disable active-cell attachment' : 'Enable active-cell attachment'}
3320
3457
  disabled={!showCellAttachmentBadge}
3321
3458
  tabIndex={showCellAttachmentBadge ? 0 : -1}
3322
3459
  >
3323
- <XIcon width={10} height={10} />
3460
+ <CellAttachmentIcon active={cellAttachmentContentEnabled} width={15} height={15} />
3461
+ <span className="jp-CodexComposer-cellAttachmentLabel">Cell Attatch</span>
3324
3462
  </button>
3325
3463
  </div>
3326
3464
  </div>
@@ -3390,6 +3528,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
3390
3528
  setReasoningMenuOpen(false);
3391
3529
  setUsagePopoverOpen(false);
3392
3530
  setPermissionMenuOpen(false);
3531
+ setContextPopoverOpen(false);
3393
3532
  }}
3394
3533
  disabled={status === 'running'}
3395
3534
  aria-label={`Model: ${selectedModelLabel}`}
@@ -3446,6 +3585,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
3446
3585
  setModelMenuOpen(false);
3447
3586
  setUsagePopoverOpen(false);
3448
3587
  setPermissionMenuOpen(false);
3588
+ setContextPopoverOpen(false);
3449
3589
  }}
3450
3590
  disabled={status === 'running'}
3451
3591
  aria-label={`Reasoning: ${selectedReasoningLabel}`}
@@ -3510,6 +3650,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
3510
3650
  setModelMenuOpen(false);
3511
3651
  setReasoningMenuOpen(false);
3512
3652
  setUsagePopoverOpen(false);
3653
+ setContextPopoverOpen(false);
3513
3654
  }}
3514
3655
  disabled={status === 'running'}
3515
3656
  aria-label={`Permission: ${selectedSandboxLabel}`}
@@ -3544,10 +3685,18 @@ function CodexChat(props: CodexChatProps): JSX.Element {
3544
3685
  ))}
3545
3686
  </PortalMenu>
3546
3687
  {hasContextUsageSnapshot && (
3547
- <div className="jp-CodexContextWrap">
3688
+ <div
3689
+ className="jp-CodexContextWrap"
3690
+ ref={contextMenuWrapRef}
3691
+ onMouseEnter={openContextPopover}
3692
+ onMouseLeave={scheduleCloseContextPopover}
3693
+ onFocusCapture={openContextPopover}
3694
+ onBlurCapture={handleContextPopoverBlur}
3695
+ >
3548
3696
  <button
3549
3697
  type="button"
3550
3698
  className={`jp-CodexIconBtn jp-CodexContextBtn${usageIsStale ? ' is-stale' : ''}`}
3699
+ ref={contextBtnRef}
3551
3700
  aria-label={
3552
3701
  contextUsedTokens == null || contextLeftTokens == null
3553
3702
  ? 'Context window usage unavailable'
@@ -3561,7 +3710,17 @@ function CodexChat(props: CodexChatProps): JSX.Element {
3561
3710
  >
3562
3711
  <ContextWindowIcon level={contextLevel} width={20} height={20} />
3563
3712
  </button>
3564
- <div className="jp-CodexContextPopover" role="tooltip">
3713
+ <PortalMenu
3714
+ open={contextPopoverOpen}
3715
+ anchorRef={contextBtnRef}
3716
+ popoverRef={contextPopoverRef}
3717
+ className="jp-CodexContextPopover"
3718
+ role="tooltip"
3719
+ ariaLabel="Context window"
3720
+ align="right"
3721
+ onMouseEnter={openContextPopover}
3722
+ onMouseLeave={scheduleCloseContextPopover}
3723
+ >
3565
3724
  <div className="jp-CodexContextPopoverTitle">Context window</div>
3566
3725
  <div className="jp-CodexContextPopoverRow">
3567
3726
  <span>Used</span>
@@ -3576,7 +3735,7 @@ function CodexChat(props: CodexChatProps): JSX.Element {
3576
3735
  ? 'Window size unavailable'
3577
3736
  : `Window: ${contextWindowLabel} tokens (${contextUsedPercentLabel} used)`}
3578
3737
  </div>
3579
- </div>
3738
+ </PortalMenu>
3580
3739
  </div>
3581
3740
  )}
3582
3741
  </div>