@agent-native/core 0.123.1 → 0.124.0

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 (86) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +20 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/deploy/build.ts +8 -0
  5. package/corpus/core/src/server/builder-browser.ts +76 -0
  6. package/corpus/core/src/server/core-routes-plugin.ts +15 -5
  7. package/corpus/templates/assets/app/global.css +0 -4
  8. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +195 -167
  9. package/corpus/templates/assets/changelog/2026-07-23-drafts-preview-panel-now-fills-the-full-width-with-a-large-p.md +6 -0
  10. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
  11. package/corpus/templates/content/actions/_builder-cms-write-client.ts +55 -9
  12. package/corpus/templates/content/actions/_database-source-utils.ts +756 -182
  13. package/corpus/templates/content/actions/change-content-database-source-role.ts +21 -20
  14. package/corpus/templates/content/actions/execute-builder-source-execution.ts +49 -8
  15. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +42 -9
  16. package/corpus/templates/content/actions/prepare-builder-source-review.ts +88 -90
  17. package/corpus/templates/content/actions/preview-builder-source-review.ts +4 -3
  18. package/corpus/templates/content/actions/refresh-content-database-source.ts +36 -9
  19. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +28 -24
  20. package/corpus/templates/content/app/components/editor/BubbleToolbar.tsx +62 -12
  21. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +91 -16
  22. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +15 -1
  23. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +234 -44
  24. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +194 -16
  25. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +95 -42
  26. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +4 -2
  27. package/corpus/templates/content/app/components/editor/extensions/CodeBlockNode.tsx +10 -166
  28. package/corpus/templates/content/app/components/editor/extensions/VideoBlock.tsx +32 -8
  29. package/corpus/templates/content/app/components/editor/extensions/VideoNode.ts +14 -0
  30. package/corpus/templates/content/app/components/editor/registrySlashItems.ts +5 -1
  31. package/corpus/templates/content/app/hooks/use-content-database.ts +59 -2
  32. package/corpus/templates/content/app/hooks/use-documents.ts +18 -26
  33. package/corpus/templates/content/app/root.tsx +1 -6
  34. package/corpus/templates/content/changelog/2026-07-24-builder-can-now-publish-approved-updates-to-entries-that-are.md +6 -0
  35. package/corpus/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md +6 -0
  36. package/corpus/templates/content/changelog/2026-07-24-builder-connections-now-complete-securely-from-netlify-deplo.md +6 -0
  37. package/corpus/templates/content/changelog/2026-07-24-builder-draft-creation-now-scopes-execution-to-the-selected-.md +6 -0
  38. package/corpus/templates/content/changelog/2026-07-24-builder-review-now-distinguishes-preparing-a-safe-dry-run-from.md +6 -0
  39. package/corpus/templates/content/changelog/2026-07-24-builder-source-imports-now-recover-from-a-transient-paginati.md +6 -0
  40. package/corpus/templates/content/changelog/2026-07-24-builder-source-refresh-now-preserves-the-provider-bound-fiel.md +6 -0
  41. package/corpus/templates/content/changelog/2026-07-24-builder-update-reviews-now-open-reliably-on-large-collection.md +6 -0
  42. package/corpus/templates/content/changelog/2026-07-24-builder-write-policy-controls-now-reflect-successful-changes.md +6 -0
  43. package/corpus/templates/content/changelog/2026-07-24-date-properties-now-save-reliably-when-selected-with-the-nat.md +6 -0
  44. package/corpus/templates/content/changelog/2026-07-24-editor-slash-search-now-keeps-multi-word-block-names-open.md +6 -0
  45. package/corpus/templates/content/changelog/2026-07-24-exact-editor-slash-commands-now-execute-on-enter-even-if-the.md +6 -0
  46. package/corpus/templates/content/changelog/2026-07-24-new-collaborative-pages-now-save-their-first-edits.md +6 -0
  47. package/corpus/templates/content/changelog/2026-07-24-new-local-builder-rows-no-longer-remain-locked-behind-a-none.md +6 -0
  48. package/corpus/templates/content/changelog/2026-07-24-opening-or-closing-a-collaborative-document-no-longer-lets-u.md +6 -0
  49. package/corpus/templates/content/changelog/2026-07-24-selected-text-now-opens-the-link-editor-with-command-k-or-co.md +6 -0
  50. package/corpus/templates/content/changelog/2026-07-24-slash-menu-block-choices-no-longer-stall-pointer-interaction.md +6 -0
  51. package/corpus/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md +6 -0
  52. package/corpus/templates/content/changelog/2026-07-24-video-link-insertion-now-keeps-its-source-panel-open-while-t.md +6 -0
  53. package/corpus/templates/content/changelog/2026-07-26-builder-publishing-now-shows-safe-validation-details-instead.md +6 -0
  54. package/corpus/templates/content/changelog/2026-07-26-builder-reviews-stay-responsive-on-large-collections-and-pre.md +6 -0
  55. package/corpus/templates/content/changelog/2026-07-26-collaborative-documents-no-longer-flash-duplicated-recent-bl.md +6 -0
  56. package/corpus/templates/content/changelog/2026-07-26-corrected-builder-updates-are-now-reviewed-separately-from-f.md +6 -0
  57. package/corpus/templates/content/changelog/2026-07-26-documents-now-remain-read-only-until-their-live-collaborativ.md +6 -0
  58. package/corpus/templates/content/changelog/2026-07-26-slash-command-menus-now-stay-reachable-when-editing-near-the.md +6 -0
  59. package/corpus/templates/content/changelog/2026-07-26-the-slash-menu-now-shows-one-clear-callout-choice-instead-of.md +6 -0
  60. package/corpus/templates/content/shared/api.ts +1 -0
  61. package/corpus/templates/content/shared/builder-mdx.ts +73 -16
  62. package/corpus/templates/design/server/handlers/index-design-system-with-builder.ts +38 -21
  63. package/corpus/templates/slides/server/handlers/index-design-system-with-builder.ts +23 -20
  64. package/corpus/toolkit/CHANGELOG.md +6 -0
  65. package/corpus/toolkit/package.json +1 -1
  66. package/corpus/toolkit/src/editor/useCollabReconcile.ts +14 -1
  67. package/dist/collab/awareness.d.ts +2 -2
  68. package/dist/collab/awareness.d.ts.map +1 -1
  69. package/dist/collab/routes.d.ts +1 -1
  70. package/dist/deploy/build.js +6 -0
  71. package/dist/deploy/build.js.map +1 -1
  72. package/dist/notifications/routes.d.ts +3 -3
  73. package/dist/observability/routes.d.ts +3 -3
  74. package/dist/server/builder-browser.d.ts +13 -0
  75. package/dist/server/builder-browser.d.ts.map +1 -1
  76. package/dist/server/builder-browser.js +59 -0
  77. package/dist/server/builder-browser.js.map +1 -1
  78. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  79. package/dist/server/core-routes-plugin.js +10 -5
  80. package/dist/server/core-routes-plugin.js.map +1 -1
  81. package/dist/server/realtime-token.d.ts +1 -1
  82. package/dist/server/transcribe-voice.d.ts +1 -1
  83. package/package.json +2 -2
  84. package/src/deploy/build.ts +8 -0
  85. package/src/server/builder-browser.ts +76 -0
  86. package/src/server/core-routes-plugin.ts +15 -5
@@ -56,7 +56,7 @@ import { buildRegistrySlashItems } from "./registrySlashItems";
56
56
  interface SlashCommandMenuProps {
57
57
  editor: Editor;
58
58
  documentId?: string;
59
- onDraftCommitted?: () => void | Promise<void>;
59
+ onDraftCommitted?: () => boolean | void | Promise<boolean | void>;
60
60
  onDraftPersisted?: (markdown: string) => boolean | Promise<boolean>;
61
61
  /**
62
62
  * The open document's linked Notion page id, when it has one. When set, the
@@ -69,10 +69,76 @@ interface SlashCommandMenuProps {
69
69
  }
70
70
 
71
71
  interface EditorMenuPosition {
72
- top: number;
72
+ top?: number;
73
+ bottom?: number;
73
74
  left: number;
74
75
  }
75
76
 
77
+ const SLASH_MENU_PREFERRED_HEIGHT = 360;
78
+ const SLASH_MENU_GAP = 4;
79
+
80
+ export function getSlashMenuVerticalPosition(
81
+ anchor: { top: number; bottom: number },
82
+ container: { top: number; bottom: number },
83
+ viewportHeight: number,
84
+ ): Pick<EditorMenuPosition, "top" | "bottom"> {
85
+ const spaceBelow = viewportHeight - anchor.bottom;
86
+ const spaceAbove = anchor.top;
87
+ if (spaceBelow < SLASH_MENU_PREFERRED_HEIGHT && spaceAbove > spaceBelow) {
88
+ return {
89
+ bottom: container.bottom - anchor.top + SLASH_MENU_GAP,
90
+ };
91
+ }
92
+ return {
93
+ top: anchor.bottom - container.top + SLASH_MENU_GAP,
94
+ };
95
+ }
96
+
97
+ export function getSlashMenuPosition(editor: Editor): EditorMenuPosition {
98
+ const wrapper = editor.view.dom.closest(".visual-editor-wrapper");
99
+ const containerRect = (
100
+ wrapper instanceof HTMLElement ? wrapper : editor.view.dom
101
+ ).getBoundingClientRect();
102
+
103
+ try {
104
+ const coords = editor.view.coordsAtPos(editor.state.selection.from);
105
+ return {
106
+ ...getSlashMenuVerticalPosition(
107
+ coords,
108
+ containerRect,
109
+ window.innerHeight,
110
+ ),
111
+ left: coords.left - containerRect.left,
112
+ };
113
+ } catch {
114
+ // Collaborative reconciliation can briefly leave ProseMirror's DOM mapping
115
+ // behind the document selection. The slash transaction is still valid; use
116
+ // its nearest DOM node (or the editor origin) so the command menu remains
117
+ // available instead of turning the user's slash into inert text.
118
+ try {
119
+ const domAtSelection = editor.view.domAtPos(editor.state.selection.from);
120
+ const element =
121
+ domAtSelection.node instanceof Element
122
+ ? domAtSelection.node
123
+ : domAtSelection.node.parentElement;
124
+ const rect = element?.getBoundingClientRect();
125
+ if (rect) {
126
+ return {
127
+ ...getSlashMenuVerticalPosition(
128
+ rect,
129
+ containerRect,
130
+ window.innerHeight,
131
+ ),
132
+ left: rect.left - containerRect.left,
133
+ };
134
+ }
135
+ } catch {
136
+ // The editor origin below is a safe, visible final fallback.
137
+ }
138
+ return { top: 4, left: 0 };
139
+ }
140
+ }
141
+
76
142
  interface EquationDraft {
77
143
  displayMode: boolean;
78
144
  insertionRange: { from: number; to: number };
@@ -93,6 +159,33 @@ export interface CommandItem {
93
159
  ) => void | boolean | Promise<void>;
94
160
  }
95
161
 
162
+ export function excludeCommandsWithDuplicateTitles<T extends { title: string }>(
163
+ primaryCommands: readonly T[],
164
+ candidateCommands: readonly T[],
165
+ ): T[] {
166
+ const primaryTitles = new Set(
167
+ primaryCommands.map((command) => command.title.trim().toLocaleLowerCase()),
168
+ );
169
+ return candidateCommands.filter(
170
+ (command) => !primaryTitles.has(command.title.trim().toLocaleLowerCase()),
171
+ );
172
+ }
173
+
174
+ export type MediaPlaceholderType = "image" | "video" | "audio";
175
+
176
+ export function insertMediaPlaceholder(
177
+ editor: Editor,
178
+ type: MediaPlaceholderType,
179
+ ) {
180
+ const attrs =
181
+ type === "image"
182
+ ? { src: null, alt: "" }
183
+ : type === "video"
184
+ ? { src: null, sourcePanelOpen: true }
185
+ : { src: null };
186
+ return editor.chain().focus().insertContent({ type, attrs }).run();
187
+ }
188
+
96
189
  function getActiveSlashCommandRange(editor: Editor) {
97
190
  const { state } = editor;
98
191
  if (!state.selection.empty) return null;
@@ -219,7 +312,16 @@ export function parseInlineGeneratePrompt(textBeforeCursor: string) {
219
312
  }
220
313
 
221
314
  export function parseSlashCommandQuery(textBeforeCursor: string) {
222
- return textBeforeCursor.match(/^\s*\/([a-zA-Z0-9]*)$/)?.[1] ?? null;
315
+ const match = textBeforeCursor.match(
316
+ /^\s*\/([a-zA-Z0-9][a-zA-Z0-9 _-]*|)\s*$/,
317
+ );
318
+ if (!match) return null;
319
+ const rawQuery = match[1] ?? "";
320
+ // `/generate <prompt>` intentionally leaves the menu so Enter can submit the
321
+ // inline prompt. Other multi-word labels (for example `/heading 2`) remain
322
+ // searchable instead of turning into literal editor text at the first space.
323
+ if (/^generate\s+/i.test(rawQuery)) return null;
324
+ return rawQuery.trim();
223
325
  }
224
326
 
225
327
  export function inlineDatabaseBlockContent(
@@ -293,6 +395,15 @@ export function getEquationInsertionRange(
293
395
  : slashRange;
294
396
  }
295
397
 
398
+ export function setCodeBlockFromSlashCommand(
399
+ editor: Editor,
400
+ slashRange: { from: number; to: number } | null,
401
+ ) {
402
+ const chain = editor.chain().focus();
403
+ if (slashRange) chain.deleteRange(slashRange);
404
+ return chain.setCodeBlock().run();
405
+ }
406
+
296
407
  const commands: CommandTemplate[] = [
297
408
  {
298
409
  titleKey: "editor.slash.text",
@@ -345,7 +456,9 @@ const commands: CommandTemplate[] = [
345
456
  descriptionKey: "editor.slash.codeBlockDescription",
346
457
  shortcut: "```",
347
458
  icon: IconCode,
348
- action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
459
+ preserveSlashRange: true,
460
+ action: (editor, { slashRange }) =>
461
+ setCodeBlockFromSlashCommand(editor, slashRange),
349
462
  },
350
463
  {
351
464
  titleKey: "editor.slash.quote",
@@ -450,7 +563,9 @@ const turnIntoCommands: CommandTemplate[] = [
450
563
  descriptionKey: "editor.slash.codeBlockDescription",
451
564
  shortcut: "```",
452
565
  icon: IconCode,
453
- action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
566
+ preserveSlashRange: true,
567
+ action: (editor, { slashRange }) =>
568
+ setCodeBlockFromSlashCommand(editor, slashRange),
454
569
  },
455
570
  {
456
571
  titleKey: "editor.slash.quote",
@@ -545,16 +660,7 @@ export function SlashCommandMenu({
545
660
  );
546
661
 
547
662
  const getSelectionMenuPosition = useCallback(() => {
548
- const coords = editor.view.coordsAtPos(editor.state.selection.from);
549
- const editorRect = editor.view.dom
550
- .closest(".visual-editor-wrapper")
551
- ?.getBoundingClientRect();
552
- if (!editorRect) return null;
553
-
554
- return {
555
- top: coords.bottom - editorRect.top + 4,
556
- left: coords.left - editorRect.left,
557
- };
663
+ return getSlashMenuPosition(editor);
558
664
  }, [editor]);
559
665
 
560
666
  const openGeneratePopover = useCallback(
@@ -598,11 +704,7 @@ export function SlashCommandMenu({
598
704
  description: t("editor.slash.imageDescription"),
599
705
  icon: IconPhoto,
600
706
  action: (editor) => {
601
- editor
602
- .chain()
603
- .focus()
604
- .insertContent({ type: "image", attrs: { src: null, alt: "" } })
605
- .run();
707
+ insertMediaPlaceholder(editor, "image");
606
708
  },
607
709
  };
608
710
 
@@ -611,11 +713,7 @@ export function SlashCommandMenu({
611
713
  description: t("editor.slash.videoDescription"),
612
714
  icon: IconVideo,
613
715
  action: (editor) => {
614
- editor
615
- .chain()
616
- .focus()
617
- .insertContent({ type: "video", attrs: { src: null } })
618
- .run();
716
+ insertMediaPlaceholder(editor, "video");
619
717
  },
620
718
  };
621
719
 
@@ -624,11 +722,7 @@ export function SlashCommandMenu({
624
722
  description: t("editor.slash.audioDescription"),
625
723
  icon: IconMusic,
626
724
  action: (editor) => {
627
- editor
628
- .chain()
629
- .focus()
630
- .insertContent({ type: "audio", attrs: { src: null } })
631
- .run();
725
+ insertMediaPlaceholder(editor, "audio");
632
726
  },
633
727
  };
634
728
 
@@ -855,6 +949,10 @@ export function SlashCommandMenu({
855
949
  ...(isTurnInto ? turnIntoCommands : commands).map(localizeCommand),
856
950
  ...equationCommands,
857
951
  ];
952
+ const uniqueRegistryCommands = excludeCommandsWithDuplicateTitles(
953
+ blockCommands,
954
+ registryCommands,
955
+ );
858
956
  const pageCommands = isTurnInto ? [] : [pageCommand, databaseCommand];
859
957
  const mediaCommands = isTurnInto
860
958
  ? []
@@ -866,11 +964,20 @@ export function SlashCommandMenu({
866
964
  cmd.searchText?.toLowerCase().includes(normalizedQuery);
867
965
  const filteredAiCommands = aiCommands.filter(commandMatchesQuery);
868
966
  const filteredBlockCommands = blockCommands.filter(commandMatchesQuery);
869
- const filteredRegistryCommands = registryCommands.filter(commandMatchesQuery);
967
+ const filteredRegistryCommands =
968
+ uniqueRegistryCommands.filter(commandMatchesQuery);
870
969
  const filteredLocalComponentCommands =
871
970
  localComponentCommands.filter(commandMatchesQuery);
872
971
  const filteredPageCommands = pageCommands.filter(commandMatchesQuery);
873
972
  const filteredMediaCommands = mediaCommands.filter(commandMatchesQuery);
973
+ const allCommands = [
974
+ ...aiCommands,
975
+ ...blockCommands,
976
+ ...uniqueRegistryCommands,
977
+ ...localComponentCommands,
978
+ ...mediaCommands,
979
+ ...pageCommands,
980
+ ];
874
981
  const filteredCommands = [
875
982
  ...filteredAiCommands,
876
983
  ...filteredBlockCommands,
@@ -899,6 +1006,8 @@ export function SlashCommandMenu({
899
1006
 
900
1007
  const executeCommand = useCallback(
901
1008
  async (cmd: CommandItem) => {
1009
+ if (editor.isDestroyed) return;
1010
+ const beforeDoc = editor.state.doc;
902
1011
  const slashRange =
903
1012
  getActiveSlashCommandRange(editor) ??
904
1013
  (slashPosRef.current !== null
@@ -912,14 +1021,52 @@ export function SlashCommandMenu({
912
1021
  setQuery("");
913
1022
  slashPosRef.current = null;
914
1023
  await cmd.action(editor, { slashRange });
1024
+ // Structural slash commands (especially an empty table) can be followed
1025
+ // immediately by another modal command or navigation before the normal
1026
+ // debounced onUpdate save settles. Persist the completed command now so
1027
+ // the durable snapshot cannot omit the block. Media placeholders are
1028
+ // still held by VisualEditor's pending-media guard until they have a src.
1029
+ if (!editor.isDestroyed && !editor.state.doc.eq(beforeDoc)) {
1030
+ const persisted = await onDraftCommitted?.();
1031
+ if (persisted === false) {
1032
+ toast.error(t("empty.genericError"));
1033
+ }
1034
+ }
915
1035
  },
916
- [editor],
1036
+ [editor, onDraftCommitted, t],
917
1037
  );
918
1038
 
919
1039
  useEffect(() => {
920
1040
  if (!editor) return;
921
1041
 
922
1042
  const handleKeyDown = (e: KeyboardEvent) => {
1043
+ if (
1044
+ e.key === "Enter" &&
1045
+ !e.shiftKey &&
1046
+ !e.metaKey &&
1047
+ !e.ctrlKey &&
1048
+ !e.altKey
1049
+ ) {
1050
+ const slashRange = getActiveSlashCommandRange(editor);
1051
+ const liveQuery = slashRange
1052
+ ? editor.state.doc
1053
+ .textBetween(slashRange.from + 1, slashRange.to, "\n")
1054
+ .trim()
1055
+ .toLowerCase()
1056
+ : "";
1057
+ const exactCommand = liveQuery
1058
+ ? allCommands.find(
1059
+ (command) => command.title.toLowerCase() === liveQuery,
1060
+ )
1061
+ : undefined;
1062
+ if (exactCommand) {
1063
+ e.preventDefault();
1064
+ e.stopPropagation();
1065
+ void executeCommand(exactCommand);
1066
+ return;
1067
+ }
1068
+ }
1069
+
923
1070
  if (!isOpen) {
924
1071
  if (
925
1072
  e.key === "Enter" &&
@@ -986,6 +1133,7 @@ export function SlashCommandMenu({
986
1133
  openGeneratePopover,
987
1134
  readInlineGenerateCommand,
988
1135
  submitGeneratePrompt,
1136
+ allCommands,
989
1137
  ]);
990
1138
 
991
1139
  useEffect(() => {
@@ -1043,16 +1191,7 @@ export function SlashCommandMenu({
1043
1191
  const slashAtBlockStart = offsetInParent === 0;
1044
1192
  setIsTurnInto(slashAtBlockStart && blockHasOtherContent);
1045
1193
 
1046
- const coords = editor.view.coordsAtPos(from);
1047
- const editorRect = editor.view.dom
1048
- .closest(".visual-editor-wrapper")
1049
- ?.getBoundingClientRect();
1050
- if (editorRect) {
1051
- setPosition({
1052
- top: coords.bottom - editorRect.top + 4,
1053
- left: coords.left - editorRect.left,
1054
- });
1055
- }
1194
+ setPosition(getSlashMenuPosition(editor));
1056
1195
  setIsOpen(true);
1057
1196
  } else {
1058
1197
  if (isOpen) {
@@ -1070,6 +1209,36 @@ export function SlashCommandMenu({
1070
1209
  };
1071
1210
  }, [editor, isOpen]);
1072
1211
 
1212
+ useEffect(() => {
1213
+ if (!isOpen || editor.isDestroyed) return;
1214
+
1215
+ let frame = 0;
1216
+ const updatePosition = () => {
1217
+ cancelAnimationFrame(frame);
1218
+ frame = requestAnimationFrame(() => {
1219
+ if (!editor.isDestroyed) {
1220
+ setPosition(getSlashMenuPosition(editor));
1221
+ }
1222
+ });
1223
+ };
1224
+
1225
+ // ProseMirror can scroll any ancestor after the slash transaction to reveal
1226
+ // the caret. Recalculate after layout and capture those non-bubbling scroll
1227
+ // events so a menu near the viewport edge flips using current geometry.
1228
+ updatePosition();
1229
+ document.addEventListener("scroll", updatePosition, true);
1230
+ window.addEventListener("resize", updatePosition);
1231
+ window.visualViewport?.addEventListener("resize", updatePosition);
1232
+ window.visualViewport?.addEventListener("scroll", updatePosition);
1233
+ return () => {
1234
+ cancelAnimationFrame(frame);
1235
+ document.removeEventListener("scroll", updatePosition, true);
1236
+ window.removeEventListener("resize", updatePosition);
1237
+ window.visualViewport?.removeEventListener("resize", updatePosition);
1238
+ window.visualViewport?.removeEventListener("scroll", updatePosition);
1239
+ };
1240
+ }, [editor, isOpen]);
1241
+
1073
1242
  return (
1074
1243
  <>
1075
1244
  {/* Slash command menu */}
@@ -1080,8 +1249,11 @@ export function SlashCommandMenu({
1080
1249
  style={{
1081
1250
  position: "absolute",
1082
1251
  top: position.top,
1252
+ bottom: position.bottom,
1083
1253
  left: 0,
1084
1254
  right: 0,
1255
+ maxHeight: "min(360px, calc(100vh - 2rem))",
1256
+ overflowY: "auto",
1085
1257
  maxWidth: "min(330px, calc(100vw - 2rem))",
1086
1258
  marginLeft: Math.min(position.left, 16),
1087
1259
  zIndex: 50,
@@ -1293,7 +1465,7 @@ export function SlashCommandMenu({
1293
1465
  );
1294
1466
  }
1295
1467
 
1296
- function CommandButton({
1468
+ export function CommandButton({
1297
1469
  cmd,
1298
1470
  isSelected,
1299
1471
  buttonRef,
@@ -1306,13 +1478,31 @@ function CommandButton({
1306
1478
  onExecute: () => void;
1307
1479
  onHover: () => void;
1308
1480
  }) {
1481
+ const pendingExecutionRef = useRef(false);
1482
+ const onExecuteRef = useRef(onExecute);
1483
+ onExecuteRef.current = onExecute;
1484
+
1485
+ const executeOnce = () => {
1486
+ if (pendingExecutionRef.current) return;
1487
+ pendingExecutionRef.current = true;
1488
+ // Pointer selection can close and unmount the menu before the browser
1489
+ // dispatches `click`. Start the command during mouse down, while the
1490
+ // editor selection and button are both still alive. Keep `onClick` as the
1491
+ // keyboard-generated click fallback and dedupe the normal pointer click.
1492
+ onExecuteRef.current();
1493
+ queueMicrotask(() => {
1494
+ pendingExecutionRef.current = false;
1495
+ });
1496
+ };
1497
+
1309
1498
  return (
1310
1499
  <button
1311
1500
  ref={buttonRef}
1312
1501
  onMouseDown={(event) => {
1313
1502
  event.preventDefault();
1314
- onExecute();
1503
+ if (event.button === 0) executeOnce();
1315
1504
  }}
1505
+ onClick={executeOnce}
1316
1506
  onMouseEnter={onHover}
1317
1507
  className={cn(
1318
1508
  "flex min-h-9 w-full items-center gap-3 px-3 py-1 text-left transition-colors",