@agent-native/toolkit 0.5.1 → 0.7.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 (349) hide show
  1. package/README.md +17 -1
  2. package/dist/chat-history/ChatHistoryList.d.ts +103 -0
  3. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
  4. package/dist/chat-history/ChatHistoryList.js +109 -0
  5. package/dist/chat-history/ChatHistoryList.js.map +1 -0
  6. package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
  7. package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
  8. package/dist/chat-history/ChatHistoryList.spec.js +283 -0
  9. package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
  10. package/dist/chat-history/index.d.ts +2 -0
  11. package/dist/chat-history/index.d.ts.map +1 -0
  12. package/dist/chat-history/index.js +2 -0
  13. package/dist/chat-history/index.js.map +1 -0
  14. package/dist/chat-history.css +314 -0
  15. package/dist/composer/AgentComposerFrame.d.ts +19 -0
  16. package/dist/composer/AgentComposerFrame.d.ts.map +1 -0
  17. package/dist/composer/AgentComposerFrame.js +14 -0
  18. package/dist/composer/AgentComposerFrame.js.map +1 -0
  19. package/dist/composer/ComposerPlusMenu.d.ts +17 -0
  20. package/dist/composer/ComposerPlusMenu.d.ts.map +1 -0
  21. package/dist/composer/ComposerPlusMenu.js +501 -0
  22. package/dist/composer/ComposerPlusMenu.js.map +1 -0
  23. package/dist/composer/MentionPopover.d.ts +29 -0
  24. package/dist/composer/MentionPopover.d.ts.map +1 -0
  25. package/dist/composer/MentionPopover.js +180 -0
  26. package/dist/composer/MentionPopover.js.map +1 -0
  27. package/dist/composer/PastedTextChip.d.ts +9 -0
  28. package/dist/composer/PastedTextChip.d.ts.map +1 -0
  29. package/dist/composer/PastedTextChip.js +47 -0
  30. package/dist/composer/PastedTextChip.js.map +1 -0
  31. package/dist/composer/PromptComposer.d.ts +114 -0
  32. package/dist/composer/PromptComposer.d.ts.map +1 -0
  33. package/dist/composer/PromptComposer.js +302 -0
  34. package/dist/composer/PromptComposer.js.map +1 -0
  35. package/dist/composer/PromptComposer.spec.d.ts +2 -0
  36. package/dist/composer/PromptComposer.spec.d.ts.map +1 -0
  37. package/dist/composer/PromptComposer.spec.js +64 -0
  38. package/dist/composer/PromptComposer.spec.js.map +1 -0
  39. package/dist/composer/RealtimeVoiceMode.d.ts +104 -0
  40. package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -0
  41. package/dist/composer/RealtimeVoiceMode.js +245 -0
  42. package/dist/composer/RealtimeVoiceMode.js.map +1 -0
  43. package/dist/composer/RealtimeVoiceMode.spec.d.ts +2 -0
  44. package/dist/composer/RealtimeVoiceMode.spec.d.ts.map +1 -0
  45. package/dist/composer/RealtimeVoiceMode.spec.js +439 -0
  46. package/dist/composer/RealtimeVoiceMode.spec.js.map +1 -0
  47. package/dist/composer/TiptapComposer.d.ts +221 -0
  48. package/dist/composer/TiptapComposer.d.ts.map +1 -0
  49. package/dist/composer/TiptapComposer.js +1838 -0
  50. package/dist/composer/TiptapComposer.js.map +1 -0
  51. package/dist/composer/TiptapComposer.spec.d.ts +2 -0
  52. package/dist/composer/TiptapComposer.spec.d.ts.map +1 -0
  53. package/dist/composer/TiptapComposer.spec.js +277 -0
  54. package/dist/composer/TiptapComposer.spec.js.map +1 -0
  55. package/dist/composer/VoiceButton.d.ts +25 -0
  56. package/dist/composer/VoiceButton.d.ts.map +1 -0
  57. package/dist/composer/VoiceButton.js +100 -0
  58. package/dist/composer/VoiceButton.js.map +1 -0
  59. package/dist/composer/VoiceButton.spec.d.ts +2 -0
  60. package/dist/composer/VoiceButton.spec.d.ts.map +1 -0
  61. package/dist/composer/VoiceButton.spec.js +19 -0
  62. package/dist/composer/VoiceButton.spec.js.map +1 -0
  63. package/dist/composer/asset-picker-url.d.ts +16 -0
  64. package/dist/composer/asset-picker-url.d.ts.map +1 -0
  65. package/dist/composer/asset-picker-url.js +64 -0
  66. package/dist/composer/asset-picker-url.js.map +1 -0
  67. package/dist/composer/asset-picker-url.spec.d.ts +2 -0
  68. package/dist/composer/asset-picker-url.spec.d.ts.map +1 -0
  69. package/dist/composer/asset-picker-url.spec.js +23 -0
  70. package/dist/composer/asset-picker-url.spec.js.map +1 -0
  71. package/dist/composer/attachment-accept.d.ts +9 -0
  72. package/dist/composer/attachment-accept.d.ts.map +1 -0
  73. package/dist/composer/attachment-accept.js +65 -0
  74. package/dist/composer/attachment-accept.js.map +1 -0
  75. package/dist/composer/attachment-accept.spec.d.ts +2 -0
  76. package/dist/composer/attachment-accept.spec.d.ts.map +1 -0
  77. package/dist/composer/attachment-accept.spec.js +15 -0
  78. package/dist/composer/attachment-accept.spec.js.map +1 -0
  79. package/dist/composer/draft-key.d.ts +2 -0
  80. package/dist/composer/draft-key.d.ts.map +1 -0
  81. package/dist/composer/draft-key.js +8 -0
  82. package/dist/composer/draft-key.js.map +1 -0
  83. package/dist/composer/draft-key.spec.d.ts +2 -0
  84. package/dist/composer/draft-key.spec.d.ts.map +1 -0
  85. package/dist/composer/draft-key.spec.js +13 -0
  86. package/dist/composer/draft-key.spec.js.map +1 -0
  87. package/dist/composer/extensions/FileReference.d.ts +3 -0
  88. package/dist/composer/extensions/FileReference.d.ts.map +1 -0
  89. package/dist/composer/extensions/FileReference.js +36 -0
  90. package/dist/composer/extensions/FileReference.js.map +1 -0
  91. package/dist/composer/extensions/MentionReference.d.ts +3 -0
  92. package/dist/composer/extensions/MentionReference.d.ts.map +1 -0
  93. package/dist/composer/extensions/MentionReference.js +66 -0
  94. package/dist/composer/extensions/MentionReference.js.map +1 -0
  95. package/dist/composer/extensions/SkillReference.d.ts +3 -0
  96. package/dist/composer/extensions/SkillReference.d.ts.map +1 -0
  97. package/dist/composer/extensions/SkillReference.js +40 -0
  98. package/dist/composer/extensions/SkillReference.js.map +1 -0
  99. package/dist/composer/index.d.ts +14 -0
  100. package/dist/composer/index.d.ts.map +1 -0
  101. package/dist/composer/index.js +13 -0
  102. package/dist/composer/index.js.map +1 -0
  103. package/dist/composer/pasted-text.d.ts +31 -0
  104. package/dist/composer/pasted-text.d.ts.map +1 -0
  105. package/dist/composer/pasted-text.js +130 -0
  106. package/dist/composer/pasted-text.js.map +1 -0
  107. package/dist/composer/pasted-text.spec.d.ts +2 -0
  108. package/dist/composer/pasted-text.spec.d.ts.map +1 -0
  109. package/dist/composer/pasted-text.spec.js +107 -0
  110. package/dist/composer/pasted-text.spec.js.map +1 -0
  111. package/dist/composer/prompt-attachments.d.ts +18 -0
  112. package/dist/composer/prompt-attachments.d.ts.map +1 -0
  113. package/dist/composer/prompt-attachments.js +65 -0
  114. package/dist/composer/prompt-attachments.js.map +1 -0
  115. package/dist/composer/prompt-attachments.spec.d.ts +2 -0
  116. package/dist/composer/prompt-attachments.spec.d.ts.map +1 -0
  117. package/dist/composer/prompt-attachments.spec.js +63 -0
  118. package/dist/composer/prompt-attachments.spec.js.map +1 -0
  119. package/dist/composer/realtime-voice-audio-level.d.ts +14 -0
  120. package/dist/composer/realtime-voice-audio-level.d.ts.map +1 -0
  121. package/dist/composer/realtime-voice-audio-level.js +47 -0
  122. package/dist/composer/realtime-voice-audio-level.js.map +1 -0
  123. package/dist/composer/realtime-voice-audio-level.spec.d.ts +2 -0
  124. package/dist/composer/realtime-voice-audio-level.spec.d.ts.map +1 -0
  125. package/dist/composer/realtime-voice-audio-level.spec.js +25 -0
  126. package/dist/composer/realtime-voice-audio-level.spec.js.map +1 -0
  127. package/dist/composer/realtime-voice-transcript.d.ts +26 -0
  128. package/dist/composer/realtime-voice-transcript.d.ts.map +1 -0
  129. package/dist/composer/realtime-voice-transcript.js +103 -0
  130. package/dist/composer/realtime-voice-transcript.js.map +1 -0
  131. package/dist/composer/realtime-voice-transcript.spec.d.ts +2 -0
  132. package/dist/composer/realtime-voice-transcript.spec.d.ts.map +1 -0
  133. package/dist/composer/realtime-voice-transcript.spec.js +76 -0
  134. package/dist/composer/realtime-voice-transcript.spec.js.map +1 -0
  135. package/dist/composer/runtime-adapters.d.ts +163 -0
  136. package/dist/composer/runtime-adapters.d.ts.map +1 -0
  137. package/dist/composer/runtime-adapters.js +130 -0
  138. package/dist/composer/runtime-adapters.js.map +1 -0
  139. package/dist/composer/types.d.ts +60 -0
  140. package/dist/composer/types.d.ts.map +1 -0
  141. package/dist/composer/types.js +2 -0
  142. package/dist/composer/types.js.map +1 -0
  143. package/dist/composer/use-file-search.d.ts +6 -0
  144. package/dist/composer/use-file-search.d.ts.map +1 -0
  145. package/dist/composer/use-file-search.js +53 -0
  146. package/dist/composer/use-file-search.js.map +1 -0
  147. package/dist/composer/use-mention-search.d.ts +6 -0
  148. package/dist/composer/use-mention-search.d.ts.map +1 -0
  149. package/dist/composer/use-mention-search.js +75 -0
  150. package/dist/composer/use-mention-search.js.map +1 -0
  151. package/dist/composer/use-skills.d.ts +7 -0
  152. package/dist/composer/use-skills.d.ts.map +1 -0
  153. package/dist/composer/use-skills.js +40 -0
  154. package/dist/composer/use-skills.js.map +1 -0
  155. package/dist/composer/useRealtimeVoiceMode.d.ts +179 -0
  156. package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -0
  157. package/dist/composer/useRealtimeVoiceMode.js +1657 -0
  158. package/dist/composer/useRealtimeVoiceMode.js.map +1 -0
  159. package/dist/composer/useRealtimeVoiceMode.spec.d.ts +2 -0
  160. package/dist/composer/useRealtimeVoiceMode.spec.d.ts.map +1 -0
  161. package/dist/composer/useRealtimeVoiceMode.spec.js +618 -0
  162. package/dist/composer/useRealtimeVoiceMode.spec.js.map +1 -0
  163. package/dist/composer/useVoiceDictation.d.ts +57 -0
  164. package/dist/composer/useVoiceDictation.d.ts.map +1 -0
  165. package/dist/composer/useVoiceDictation.js +1004 -0
  166. package/dist/composer/useVoiceDictation.js.map +1 -0
  167. package/dist/composer/useVoiceDictation.spec.d.ts +2 -0
  168. package/dist/composer/useVoiceDictation.spec.d.ts.map +1 -0
  169. package/dist/composer/useVoiceDictation.spec.js +32 -0
  170. package/dist/composer/useVoiceDictation.spec.js.map +1 -0
  171. package/dist/editor/RegistryBlockContext.d.ts +68 -0
  172. package/dist/editor/RegistryBlockContext.d.ts.map +1 -0
  173. package/dist/editor/RegistryBlockContext.js +10 -0
  174. package/dist/editor/RegistryBlockContext.js.map +1 -0
  175. package/dist/editor/RegistryBlockNode.d.ts +66 -0
  176. package/dist/editor/RegistryBlockNode.d.ts.map +1 -0
  177. package/dist/editor/RegistryBlockNode.js +406 -0
  178. package/dist/editor/RegistryBlockNode.js.map +1 -0
  179. package/dist/editor/RegistryBlockNode.spec.d.ts +2 -0
  180. package/dist/editor/RegistryBlockNode.spec.d.ts.map +1 -0
  181. package/dist/editor/RegistryBlockNode.spec.js +215 -0
  182. package/dist/editor/RegistryBlockNode.spec.js.map +1 -0
  183. package/dist/editor/index.d.ts +2 -0
  184. package/dist/editor/index.d.ts.map +1 -1
  185. package/dist/editor/index.js +2 -0
  186. package/dist/editor/index.js.map +1 -1
  187. package/dist/editor/registrySlashCommands.d.ts +68 -0
  188. package/dist/editor/registrySlashCommands.d.ts.map +1 -0
  189. package/dist/editor/registrySlashCommands.js +51 -0
  190. package/dist/editor/registrySlashCommands.js.map +1 -0
  191. package/package.json +32 -1
  192. package/src/app-shell/header-actions.tsx +84 -0
  193. package/src/app-shell/index.ts +1 -0
  194. package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
  195. package/src/chat-history/ChatHistoryList.tsx +469 -0
  196. package/src/chat-history/index.ts +7 -0
  197. package/src/chat-history.css +314 -0
  198. package/src/collab-ui/AgentPresenceChip.tsx +69 -0
  199. package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
  200. package/src/collab-ui/PresenceBar.tsx +346 -0
  201. package/src/collab-ui/RecentEditHighlights.tsx +225 -0
  202. package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
  203. package/src/collab-ui/agent-identity.ts +16 -0
  204. package/src/collab-ui/index.ts +39 -0
  205. package/src/collab-ui/lead-client.ts +32 -0
  206. package/src/collab-ui/types.spec.ts +18 -0
  207. package/src/collab-ui/types.ts +96 -0
  208. package/src/composer/AgentComposerFrame.tsx +59 -0
  209. package/src/composer/ComposerPlusMenu.tsx +983 -0
  210. package/src/composer/MentionPopover.tsx +412 -0
  211. package/src/composer/PastedTextChip.tsx +150 -0
  212. package/src/composer/PromptComposer.spec.ts +73 -0
  213. package/src/composer/PromptComposer.tsx +718 -0
  214. package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
  215. package/src/composer/RealtimeVoiceMode.tsx +856 -0
  216. package/src/composer/TiptapComposer.spec.ts +390 -0
  217. package/src/composer/TiptapComposer.tsx +2936 -0
  218. package/src/composer/VoiceButton.spec.ts +33 -0
  219. package/src/composer/VoiceButton.tsx +255 -0
  220. package/src/composer/asset-picker-url.spec.ts +56 -0
  221. package/src/composer/asset-picker-url.ts +80 -0
  222. package/src/composer/attachment-accept.spec.ts +22 -0
  223. package/src/composer/attachment-accept.ts +71 -0
  224. package/src/composer/draft-key.spec.ts +19 -0
  225. package/src/composer/draft-key.ts +7 -0
  226. package/src/composer/extensions/FileReference.tsx +55 -0
  227. package/src/composer/extensions/MentionReference.tsx +93 -0
  228. package/src/composer/extensions/SkillReference.tsx +56 -0
  229. package/src/composer/index.ts +79 -0
  230. package/src/composer/pasted-text.spec.ts +162 -0
  231. package/src/composer/pasted-text.ts +160 -0
  232. package/src/composer/prompt-attachments.spec.ts +80 -0
  233. package/src/composer/prompt-attachments.ts +93 -0
  234. package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
  235. package/src/composer/realtime-voice-audio-level.ts +64 -0
  236. package/src/composer/realtime-voice-transcript.spec.ts +95 -0
  237. package/src/composer/realtime-voice-transcript.ts +134 -0
  238. package/src/composer/runtime-adapters.tsx +343 -0
  239. package/src/composer/types.ts +66 -0
  240. package/src/composer/use-file-search.ts +64 -0
  241. package/src/composer/use-mention-search.ts +90 -0
  242. package/src/composer/use-skills.ts +45 -0
  243. package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
  244. package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
  245. package/src/composer/useVoiceDictation.spec.ts +45 -0
  246. package/src/composer/useVoiceDictation.ts +1191 -0
  247. package/src/context-ui/ContextMeter.tsx +101 -0
  248. package/src/context-ui/ContextSegmentRow.tsx +184 -0
  249. package/src/context-ui/ContextTreemap.tsx +125 -0
  250. package/src/context-ui/ContextXRayPanel.tsx +377 -0
  251. package/src/context-ui/format.ts +42 -0
  252. package/src/context-ui/index.ts +15 -0
  253. package/src/context-ui/types.ts +43 -0
  254. package/src/design-tweaks/index.ts +1 -0
  255. package/src/design-tweaks/visual-style-controls.tsx +1084 -0
  256. package/src/editor/BubbleToolbar.tsx +253 -0
  257. package/src/editor/CodeBlockNode.tsx +254 -0
  258. package/src/editor/DragHandle.spec.ts +588 -0
  259. package/src/editor/DragHandle.ts +1437 -0
  260. package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
  261. package/src/editor/ImageExtension.ts +306 -0
  262. package/src/editor/RegistryBlockContext.tsx +107 -0
  263. package/src/editor/RegistryBlockNode.spec.ts +279 -0
  264. package/src/editor/RegistryBlockNode.tsx +652 -0
  265. package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
  266. package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
  267. package/src/editor/RichMarkdownEditor.tsx +81 -0
  268. package/src/editor/RunId.ts +61 -0
  269. package/src/editor/SharedRichEditor.tsx +321 -0
  270. package/src/editor/SlashCommandMenu.tsx +310 -0
  271. package/src/editor/disableHistory.spec.ts +85 -0
  272. package/src/editor/extensions.ts +402 -0
  273. package/src/editor/gfmDoc.spec.ts +230 -0
  274. package/src/editor/gfmDoc.ts +92 -0
  275. package/src/editor/index.ts +93 -0
  276. package/src/editor/registrySlashCommands.ts +131 -0
  277. package/src/editor/structuralReorderUndo.spec.ts +144 -0
  278. package/src/editor/surgical-apply.spec.ts +279 -0
  279. package/src/editor/surgical-apply.ts +192 -0
  280. package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
  281. package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
  282. package/src/editor/useCollabReconcile.ts +589 -0
  283. package/src/editor.css +510 -0
  284. package/src/hooks/index.ts +2 -0
  285. package/src/hooks/use-mobile.tsx +21 -0
  286. package/src/hooks/use-toast.ts +188 -0
  287. package/src/index.ts +8 -0
  288. package/src/onboarding/index.ts +1 -0
  289. package/src/provider.tsx +47 -0
  290. package/src/sharing/VisibilityBadge.spec.ts +96 -0
  291. package/src/sharing/VisibilityBadge.tsx +37 -0
  292. package/src/sharing/index.ts +4 -0
  293. package/src/styles.css +21 -0
  294. package/src/ui/accordion.tsx +56 -0
  295. package/src/ui/alert-dialog.tsx +141 -0
  296. package/src/ui/alert.tsx +59 -0
  297. package/src/ui/aspect-ratio.tsx +5 -0
  298. package/src/ui/avatar.tsx +48 -0
  299. package/src/ui/badge.tsx +37 -0
  300. package/src/ui/breadcrumb.tsx +115 -0
  301. package/src/ui/button.tsx +78 -0
  302. package/src/ui/calendar.tsx +214 -0
  303. package/src/ui/card.tsx +86 -0
  304. package/src/ui/carousel.tsx +260 -0
  305. package/src/ui/chart.tsx +375 -0
  306. package/src/ui/checkbox.tsx +28 -0
  307. package/src/ui/collapsible.tsx +9 -0
  308. package/src/ui/command.spec.tsx +51 -0
  309. package/src/ui/command.tsx +174 -0
  310. package/src/ui/context-menu.tsx +202 -0
  311. package/src/ui/date-picker.tsx +64 -0
  312. package/src/ui/dialog.spec.tsx +43 -0
  313. package/src/ui/dialog.tsx +146 -0
  314. package/src/ui/drawer.tsx +126 -0
  315. package/src/ui/dropdown-menu.tsx +218 -0
  316. package/src/ui/form.tsx +177 -0
  317. package/src/ui/hover-card.tsx +27 -0
  318. package/src/ui/index.ts +52 -0
  319. package/src/ui/input-otp.tsx +69 -0
  320. package/src/ui/input.tsx +22 -0
  321. package/src/ui/label.tsx +24 -0
  322. package/src/ui/logo.tsx +36 -0
  323. package/src/ui/menubar.tsx +235 -0
  324. package/src/ui/navigation-menu.tsx +128 -0
  325. package/src/ui/pagination.tsx +121 -0
  326. package/src/ui/popover.tsx +44 -0
  327. package/src/ui/progress.tsx +26 -0
  328. package/src/ui/radio-group.tsx +42 -0
  329. package/src/ui/resizable.tsx +43 -0
  330. package/src/ui/scroll-area.tsx +46 -0
  331. package/src/ui/select.tsx +158 -0
  332. package/src/ui/separator.tsx +29 -0
  333. package/src/ui/sheet.tsx +160 -0
  334. package/src/ui/sidebar.tsx +777 -0
  335. package/src/ui/skeleton.tsx +18 -0
  336. package/src/ui/slider.tsx +26 -0
  337. package/src/ui/sonner.tsx +58 -0
  338. package/src/ui/spinner.tsx +17 -0
  339. package/src/ui/switch.tsx +27 -0
  340. package/src/ui/table.tsx +117 -0
  341. package/src/ui/tabs.tsx +53 -0
  342. package/src/ui/textarea.tsx +23 -0
  343. package/src/ui/toast.tsx +127 -0
  344. package/src/ui/toaster.tsx +33 -0
  345. package/src/ui/toggle-group.tsx +59 -0
  346. package/src/ui/toggle.tsx +43 -0
  347. package/src/ui/tooltip.tsx +53 -0
  348. package/src/ui/use-toast.ts +3 -0
  349. package/src/utils.ts +8 -0
@@ -0,0 +1,422 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { useEditor, type Editor } from "@tiptap/react";
4
+ import React, { act } from "react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+
8
+ import { createRichMarkdownExtensions } from "./RichMarkdownEditor.js";
9
+ import { useCollabReconcile, getEditorMarkdown } from "./useCollabReconcile.js";
10
+
11
+ /**
12
+ * Concurrent-edit / lost-update coverage for the reconcile hook (non-collab
13
+ * controlled-value path — the same guards run there, and it's deterministic
14
+ * without a live Yjs peer). The idempotent spec covers the escalation loop;
15
+ * these cover the OTHER lost-update hazards the hook guards against:
16
+ *
17
+ * - A deliberate revert-to-a-previous-value AFTER a local edit must still land
18
+ * (it must not be swallowed as "our own echo").
19
+ * - registerEmitted must refuse to persist an empty doc in collab mode (so a
20
+ * pre-seed empty editor never writes "" over real stored content).
21
+ *
22
+ * NOTE: the "stale poll arrives WHILE the user is actively typing" guard is
23
+ * gated on `editor.isFocused`, which is always false under happy-dom (no real
24
+ * DOM focus). That path is verified in the browser E2E pass instead.
25
+ */
26
+
27
+ let container: HTMLDivElement;
28
+ let root: Root;
29
+
30
+ beforeEach(() => {
31
+ (
32
+ globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
33
+ ).IS_REACT_ACT_ENVIRONMENT = true;
34
+ container = document.createElement("div");
35
+ document.body.appendChild(container);
36
+ root = createRoot(container);
37
+ });
38
+
39
+ afterEach(() => {
40
+ act(() => root.unmount());
41
+ container.remove();
42
+ });
43
+
44
+ interface HarnessProps {
45
+ value: string;
46
+ contentUpdatedAt: string;
47
+ }
48
+
49
+ interface CollabSeedHarnessProps {
50
+ collabSynced: boolean;
51
+ fragmentLength: number;
52
+ }
53
+
54
+ interface Captured {
55
+ editor: Editor | null;
56
+ emitted: string[];
57
+ setContentCalls: number;
58
+ registerEmitted?: (markdown: string) => boolean;
59
+ }
60
+
61
+ function makeHarness() {
62
+ const captured: Captured = { editor: null, emitted: [], setContentCalls: 0 };
63
+
64
+ function Harness({ value, contentUpdatedAt }: HarnessProps) {
65
+ const guardsRef = React.useRef<ReturnType<
66
+ typeof useCollabReconcile
67
+ > | null>(null);
68
+
69
+ const editor = useEditor({
70
+ extensions: createRichMarkdownExtensions({ dialect: "gfm" }),
71
+ content: value,
72
+ onUpdate: ({ editor, transaction }) => {
73
+ const guards = guardsRef.current;
74
+ if (!guards || guards.shouldIgnoreUpdate(transaction)) return;
75
+ const markdown = getEditorMarkdown(editor);
76
+ if (!guards.registerEmitted(markdown)) return;
77
+ captured.emitted.push(markdown);
78
+ },
79
+ });
80
+ captured.editor = editor;
81
+
82
+ const guards = useCollabReconcile({
83
+ editor,
84
+ value,
85
+ contentUpdatedAt,
86
+ editable: true,
87
+ getMarkdown: getEditorMarkdown,
88
+ setContent: (ed, v, options) => {
89
+ captured.setContentCalls += 1;
90
+ if (options.addToHistory === false) {
91
+ ed.chain()
92
+ .command(({ tr }) => {
93
+ tr.setMeta("addToHistory", false);
94
+ return true;
95
+ })
96
+ .setContent(v, { emitUpdate: options.emitUpdate })
97
+ .run();
98
+ return;
99
+ }
100
+ ed.commands.setContent(v);
101
+ },
102
+ });
103
+ guardsRef.current = guards;
104
+ captured.registerEmitted = guards.registerEmitted;
105
+
106
+ return React.createElement("div", null);
107
+ }
108
+
109
+ return { captured, Harness };
110
+ }
111
+
112
+ function makeCollabSeedHarness(initialContent = "") {
113
+ const captured: Captured = { editor: null, emitted: [], setContentCalls: 0 };
114
+
115
+ function Harness({ collabSynced, fragmentLength }: CollabSeedHarnessProps) {
116
+ const guardsRef = React.useRef<ReturnType<
117
+ typeof useCollabReconcile
118
+ > | null>(null);
119
+ const fragmentLengthRef = React.useRef(fragmentLength);
120
+ fragmentLengthRef.current = fragmentLength;
121
+ const fakeYdoc = React.useMemo(
122
+ () => ({
123
+ clientID: 1,
124
+ getXmlFragment: () => ({ length: fragmentLengthRef.current }),
125
+ }),
126
+ [],
127
+ );
128
+
129
+ const editor = useEditor({
130
+ extensions: createRichMarkdownExtensions({ dialect: "gfm" }),
131
+ content: initialContent,
132
+ onUpdate: ({ editor, transaction }) => {
133
+ const guards = guardsRef.current;
134
+ if (!guards || guards.shouldIgnoreUpdate(transaction)) return;
135
+ const markdown = getEditorMarkdown(editor);
136
+ if (!guards.registerEmitted(markdown)) return;
137
+ captured.emitted.push(markdown);
138
+ },
139
+ });
140
+ captured.editor = editor;
141
+
142
+ const guards = useCollabReconcile({
143
+ editor,
144
+ ydoc: fakeYdoc as never,
145
+ collabSynced,
146
+ value: "seeded content",
147
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
148
+ editable: true,
149
+ getMarkdown: getEditorMarkdown,
150
+ setContent: (ed, v) => {
151
+ captured.setContentCalls += 1;
152
+ ed.commands.setContent(v);
153
+ },
154
+ });
155
+ guardsRef.current = guards;
156
+
157
+ return React.createElement("div", null);
158
+ }
159
+
160
+ return { captured, Harness };
161
+ }
162
+
163
+ async function flush() {
164
+ await act(async () => {
165
+ await Promise.resolve();
166
+ await new Promise((resolve) => setTimeout(resolve, 0));
167
+ await Promise.resolve();
168
+ });
169
+ }
170
+
171
+ function render(
172
+ root: Root,
173
+ Harness: (p: HarnessProps) => React.ReactElement,
174
+ props: HarnessProps,
175
+ ) {
176
+ act(() => {
177
+ root.render(React.createElement(Harness, props));
178
+ });
179
+ }
180
+
181
+ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
182
+ it("does not seed until initial collab sync has completed", async () => {
183
+ const { captured, Harness } = makeCollabSeedHarness();
184
+
185
+ act(() => {
186
+ root.render(
187
+ React.createElement(Harness, {
188
+ collabSynced: false,
189
+ fragmentLength: 0,
190
+ }),
191
+ );
192
+ });
193
+ await flush();
194
+
195
+ expect(captured.setContentCalls).toBe(0);
196
+
197
+ act(() => {
198
+ root.render(
199
+ React.createElement(Harness, {
200
+ collabSynced: true,
201
+ fragmentLength: 0,
202
+ }),
203
+ );
204
+ });
205
+ await flush();
206
+
207
+ expect(captured.setContentCalls).toBe(1);
208
+ expect(getEditorMarkdown(captured.editor!)).toBe("seeded content");
209
+ });
210
+
211
+ it("does not seed after initial collab sync reveals existing canonical content", async () => {
212
+ const { captured, Harness } = makeCollabSeedHarness("seeded content");
213
+
214
+ act(() => {
215
+ root.render(
216
+ React.createElement(Harness, {
217
+ collabSynced: false,
218
+ fragmentLength: 0,
219
+ }),
220
+ );
221
+ });
222
+ await flush();
223
+
224
+ act(() => {
225
+ root.render(
226
+ React.createElement(Harness, {
227
+ collabSynced: true,
228
+ fragmentLength: 1,
229
+ }),
230
+ );
231
+ });
232
+ await flush();
233
+
234
+ expect(captured.setContentCalls).toBe(0);
235
+ expect(getEditorMarkdown(captured.editor!)).toBe("seeded content");
236
+ });
237
+
238
+ it("applies a deliberate REVERT to a previously-applied value after a local edit (not swallowed as echo)", async () => {
239
+ // Regression for the revert-safety carve-out: the doc-equivalence echo
240
+ // guards (value === lastAppliedValueRef) only fire when the editor is
241
+ // UNCHANGED since the last apply. If the user has since edited, an external
242
+ // snapshot equal to a previously-applied value is a REAL revert (e.g. the
243
+ // agent restored an earlier version) and must land, not be skipped.
244
+ const { captured, Harness } = makeHarness();
245
+
246
+ // 1. Agent applies V1.
247
+ render(root, Harness, {
248
+ value: "# V1 content",
249
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
250
+ });
251
+ await flush();
252
+ expect(getEditorMarkdown(captured.editor!)).toBe("# V1 content");
253
+
254
+ // 2. Agent applies V2 (newer). Now lastApplied tracks V2.
255
+ render(root, Harness, {
256
+ value: "# V2 content",
257
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
258
+ });
259
+ await flush();
260
+ expect(getEditorMarkdown(captured.editor!)).toBe("# V2 content");
261
+
262
+ // 3. The agent REVERTS back to the V1 content with a NEWER timestamp (a
263
+ // genuine "undo my last change" external edit). Even though "# V1 content"
264
+ // was applied before, it must re-apply — the newer timestamp makes it a real
265
+ // external change, and the editor currently shows V2 (not V1).
266
+ render(root, Harness, {
267
+ value: "# V1 content",
268
+ contentUpdatedAt: "2024-01-01T00:00:03.000Z",
269
+ });
270
+ await flush();
271
+
272
+ expect(getEditorMarkdown(captured.editor!)).toBe("# V1 content");
273
+ });
274
+
275
+ it("applies a newer authoritative revert that matches a prior mount-time emission", async () => {
276
+ const { captured, Harness } = makeHarness();
277
+
278
+ render(root, Harness, {
279
+ value: "# V1 content",
280
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
281
+ });
282
+ await flush();
283
+ render(root, Harness, {
284
+ value: "# V2 content",
285
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
286
+ });
287
+ await flush();
288
+ expect(getEditorMarkdown(captured.editor!)).toBe("# V2 content");
289
+
290
+ // A collab mount/schema-normalization transaction can emit the old V1
291
+ // bytes even though the authoritative apply has already moved the editor
292
+ // to V2. Record that echo without focusing/typing in the editor.
293
+ expect(captured.registerEmitted?.("# V1 content")).toBe(true);
294
+
295
+ render(root, Harness, {
296
+ value: "# V1 content",
297
+ contentUpdatedAt: "2024-01-01T00:00:03.000Z",
298
+ });
299
+ await flush();
300
+
301
+ expect(getEditorMarkdown(captured.editor!)).toBe("# V1 content");
302
+ });
303
+
304
+ it("ignores local-looking editor updates until collaborative seeding completes", async () => {
305
+ const results: boolean[] = [];
306
+
307
+ function Probe() {
308
+ const editor = useEditor({
309
+ extensions: createRichMarkdownExtensions({ dialect: "gfm" }),
310
+ content: "",
311
+ });
312
+ const fakeYdoc = { clientID: 1, getXmlFragment: () => ({ length: 0 }) };
313
+ const guards = useCollabReconcile({
314
+ editor,
315
+ ydoc: fakeYdoc as never,
316
+ collabSynced: false,
317
+ value: "authoritative content",
318
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
319
+ editable: true,
320
+ });
321
+ if (editor && results.length === 0) {
322
+ results.push(guards.shouldIgnoreUpdate(editor.state.tr));
323
+ }
324
+ return React.createElement("div", null);
325
+ }
326
+
327
+ act(() => root.render(React.createElement(Probe)));
328
+ await flush();
329
+
330
+ expect(results).toEqual([true]);
331
+ });
332
+
333
+ it("refuses to persist an empty doc in collab mode (registerEmitted guard)", async () => {
334
+ // Directly exercise the guard contract: in collab mode an empty markdown
335
+ // string must not be registered/persisted (would clobber stored content
336
+ // before the shared Y.Doc seeds).
337
+ const results: boolean[] = [];
338
+
339
+ function Probe() {
340
+ const editor = useEditor({
341
+ extensions: createRichMarkdownExtensions({ dialect: "gfm" }),
342
+ content: "",
343
+ });
344
+ const fakeYdoc = { clientID: 1, getXmlFragment: () => ({ length: 0 }) };
345
+ const guards = useCollabReconcile({
346
+ editor,
347
+ ydoc: fakeYdoc as never,
348
+ value: "seeded content",
349
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
350
+ editable: true,
351
+ });
352
+ if (editor && results.length === 0) {
353
+ results.push(guards.registerEmitted(" ")); // whitespace-only → empty
354
+ results.push(guards.registerEmitted("real text")); // non-empty
355
+ }
356
+ return React.createElement("div", null);
357
+ }
358
+
359
+ act(() => root.render(React.createElement(Probe)));
360
+ await flush();
361
+
362
+ expect(results[0]).toBe(false); // empty in collab mode → refused
363
+ expect(results[1]).toBe(true); // real content → accepted
364
+ });
365
+
366
+ it("defers collab seed setContent to a cancellable timer task", async () => {
367
+ const setContentValues: string[] = [];
368
+
369
+ function Probe({ value }: { value: string }) {
370
+ const editor = useEditor({
371
+ extensions: createRichMarkdownExtensions({ dialect: "gfm" }),
372
+ content: "",
373
+ });
374
+ const fakeYdoc = { clientID: 1, getXmlFragment: () => ({ length: 0 }) };
375
+ useCollabReconcile({
376
+ editor,
377
+ ydoc: fakeYdoc as never,
378
+ value,
379
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
380
+ editable: true,
381
+ setContent: (ed, v) => {
382
+ setContentValues.push(v);
383
+ ed.commands.setContent(v);
384
+ },
385
+ });
386
+ return React.createElement("div", null);
387
+ }
388
+
389
+ act(() => root.render(React.createElement(Probe, { value: "first seed" })));
390
+ expect(setContentValues).toEqual([]);
391
+
392
+ act(() =>
393
+ root.render(React.createElement(Probe, { value: "second seed" })),
394
+ );
395
+ expect(setContentValues).toEqual([]);
396
+
397
+ await flush();
398
+
399
+ expect(setContentValues).toEqual(["second seed"]);
400
+ });
401
+
402
+ it("applies a genuinely newer external value once the user is no longer focused", async () => {
403
+ const { captured, Harness } = makeHarness();
404
+
405
+ render(root, Harness, {
406
+ value: "# Doc",
407
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
408
+ });
409
+ await flush();
410
+
411
+ // Blur the editor so the typing/focus guard does not defer.
412
+ act(() => captured.editor!.commands.blur());
413
+
414
+ render(root, Harness, {
415
+ value: "# Doc updated by agent",
416
+ contentUpdatedAt: "2024-01-01T00:00:05.000Z",
417
+ });
418
+ await flush();
419
+
420
+ expect(getEditorMarkdown(captured.editor!)).toBe("# Doc updated by agent");
421
+ });
422
+ });