@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.
- package/README.md +17 -1
- package/dist/chat-history/ChatHistoryList.d.ts +103 -0
- package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.js +109 -0
- package/dist/chat-history/ChatHistoryList.js.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.js +283 -0
- package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
- package/dist/chat-history/index.d.ts +2 -0
- package/dist/chat-history/index.d.ts.map +1 -0
- package/dist/chat-history/index.js +2 -0
- package/dist/chat-history/index.js.map +1 -0
- package/dist/chat-history.css +314 -0
- package/dist/composer/AgentComposerFrame.d.ts +19 -0
- package/dist/composer/AgentComposerFrame.d.ts.map +1 -0
- package/dist/composer/AgentComposerFrame.js +14 -0
- package/dist/composer/AgentComposerFrame.js.map +1 -0
- package/dist/composer/ComposerPlusMenu.d.ts +17 -0
- package/dist/composer/ComposerPlusMenu.d.ts.map +1 -0
- package/dist/composer/ComposerPlusMenu.js +501 -0
- package/dist/composer/ComposerPlusMenu.js.map +1 -0
- package/dist/composer/MentionPopover.d.ts +29 -0
- package/dist/composer/MentionPopover.d.ts.map +1 -0
- package/dist/composer/MentionPopover.js +180 -0
- package/dist/composer/MentionPopover.js.map +1 -0
- package/dist/composer/PastedTextChip.d.ts +9 -0
- package/dist/composer/PastedTextChip.d.ts.map +1 -0
- package/dist/composer/PastedTextChip.js +47 -0
- package/dist/composer/PastedTextChip.js.map +1 -0
- package/dist/composer/PromptComposer.d.ts +114 -0
- package/dist/composer/PromptComposer.d.ts.map +1 -0
- package/dist/composer/PromptComposer.js +302 -0
- package/dist/composer/PromptComposer.js.map +1 -0
- package/dist/composer/PromptComposer.spec.d.ts +2 -0
- package/dist/composer/PromptComposer.spec.d.ts.map +1 -0
- package/dist/composer/PromptComposer.spec.js +64 -0
- package/dist/composer/PromptComposer.spec.js.map +1 -0
- package/dist/composer/RealtimeVoiceMode.d.ts +104 -0
- package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -0
- package/dist/composer/RealtimeVoiceMode.js +245 -0
- package/dist/composer/RealtimeVoiceMode.js.map +1 -0
- package/dist/composer/RealtimeVoiceMode.spec.d.ts +2 -0
- package/dist/composer/RealtimeVoiceMode.spec.d.ts.map +1 -0
- package/dist/composer/RealtimeVoiceMode.spec.js +439 -0
- package/dist/composer/RealtimeVoiceMode.spec.js.map +1 -0
- package/dist/composer/TiptapComposer.d.ts +221 -0
- package/dist/composer/TiptapComposer.d.ts.map +1 -0
- package/dist/composer/TiptapComposer.js +1838 -0
- package/dist/composer/TiptapComposer.js.map +1 -0
- package/dist/composer/TiptapComposer.spec.d.ts +2 -0
- package/dist/composer/TiptapComposer.spec.d.ts.map +1 -0
- package/dist/composer/TiptapComposer.spec.js +277 -0
- package/dist/composer/TiptapComposer.spec.js.map +1 -0
- package/dist/composer/VoiceButton.d.ts +25 -0
- package/dist/composer/VoiceButton.d.ts.map +1 -0
- package/dist/composer/VoiceButton.js +100 -0
- package/dist/composer/VoiceButton.js.map +1 -0
- package/dist/composer/VoiceButton.spec.d.ts +2 -0
- package/dist/composer/VoiceButton.spec.d.ts.map +1 -0
- package/dist/composer/VoiceButton.spec.js +19 -0
- package/dist/composer/VoiceButton.spec.js.map +1 -0
- package/dist/composer/asset-picker-url.d.ts +16 -0
- package/dist/composer/asset-picker-url.d.ts.map +1 -0
- package/dist/composer/asset-picker-url.js +64 -0
- package/dist/composer/asset-picker-url.js.map +1 -0
- package/dist/composer/asset-picker-url.spec.d.ts +2 -0
- package/dist/composer/asset-picker-url.spec.d.ts.map +1 -0
- package/dist/composer/asset-picker-url.spec.js +23 -0
- package/dist/composer/asset-picker-url.spec.js.map +1 -0
- package/dist/composer/attachment-accept.d.ts +9 -0
- package/dist/composer/attachment-accept.d.ts.map +1 -0
- package/dist/composer/attachment-accept.js +65 -0
- package/dist/composer/attachment-accept.js.map +1 -0
- package/dist/composer/attachment-accept.spec.d.ts +2 -0
- package/dist/composer/attachment-accept.spec.d.ts.map +1 -0
- package/dist/composer/attachment-accept.spec.js +15 -0
- package/dist/composer/attachment-accept.spec.js.map +1 -0
- package/dist/composer/draft-key.d.ts +2 -0
- package/dist/composer/draft-key.d.ts.map +1 -0
- package/dist/composer/draft-key.js +8 -0
- package/dist/composer/draft-key.js.map +1 -0
- package/dist/composer/draft-key.spec.d.ts +2 -0
- package/dist/composer/draft-key.spec.d.ts.map +1 -0
- package/dist/composer/draft-key.spec.js +13 -0
- package/dist/composer/draft-key.spec.js.map +1 -0
- package/dist/composer/extensions/FileReference.d.ts +3 -0
- package/dist/composer/extensions/FileReference.d.ts.map +1 -0
- package/dist/composer/extensions/FileReference.js +36 -0
- package/dist/composer/extensions/FileReference.js.map +1 -0
- package/dist/composer/extensions/MentionReference.d.ts +3 -0
- package/dist/composer/extensions/MentionReference.d.ts.map +1 -0
- package/dist/composer/extensions/MentionReference.js +66 -0
- package/dist/composer/extensions/MentionReference.js.map +1 -0
- package/dist/composer/extensions/SkillReference.d.ts +3 -0
- package/dist/composer/extensions/SkillReference.d.ts.map +1 -0
- package/dist/composer/extensions/SkillReference.js +40 -0
- package/dist/composer/extensions/SkillReference.js.map +1 -0
- package/dist/composer/index.d.ts +14 -0
- package/dist/composer/index.d.ts.map +1 -0
- package/dist/composer/index.js +13 -0
- package/dist/composer/index.js.map +1 -0
- package/dist/composer/pasted-text.d.ts +31 -0
- package/dist/composer/pasted-text.d.ts.map +1 -0
- package/dist/composer/pasted-text.js +130 -0
- package/dist/composer/pasted-text.js.map +1 -0
- package/dist/composer/pasted-text.spec.d.ts +2 -0
- package/dist/composer/pasted-text.spec.d.ts.map +1 -0
- package/dist/composer/pasted-text.spec.js +107 -0
- package/dist/composer/pasted-text.spec.js.map +1 -0
- package/dist/composer/prompt-attachments.d.ts +18 -0
- package/dist/composer/prompt-attachments.d.ts.map +1 -0
- package/dist/composer/prompt-attachments.js +65 -0
- package/dist/composer/prompt-attachments.js.map +1 -0
- package/dist/composer/prompt-attachments.spec.d.ts +2 -0
- package/dist/composer/prompt-attachments.spec.d.ts.map +1 -0
- package/dist/composer/prompt-attachments.spec.js +63 -0
- package/dist/composer/prompt-attachments.spec.js.map +1 -0
- package/dist/composer/realtime-voice-audio-level.d.ts +14 -0
- package/dist/composer/realtime-voice-audio-level.d.ts.map +1 -0
- package/dist/composer/realtime-voice-audio-level.js +47 -0
- package/dist/composer/realtime-voice-audio-level.js.map +1 -0
- package/dist/composer/realtime-voice-audio-level.spec.d.ts +2 -0
- package/dist/composer/realtime-voice-audio-level.spec.d.ts.map +1 -0
- package/dist/composer/realtime-voice-audio-level.spec.js +25 -0
- package/dist/composer/realtime-voice-audio-level.spec.js.map +1 -0
- package/dist/composer/realtime-voice-transcript.d.ts +26 -0
- package/dist/composer/realtime-voice-transcript.d.ts.map +1 -0
- package/dist/composer/realtime-voice-transcript.js +103 -0
- package/dist/composer/realtime-voice-transcript.js.map +1 -0
- package/dist/composer/realtime-voice-transcript.spec.d.ts +2 -0
- package/dist/composer/realtime-voice-transcript.spec.d.ts.map +1 -0
- package/dist/composer/realtime-voice-transcript.spec.js +76 -0
- package/dist/composer/realtime-voice-transcript.spec.js.map +1 -0
- package/dist/composer/runtime-adapters.d.ts +163 -0
- package/dist/composer/runtime-adapters.d.ts.map +1 -0
- package/dist/composer/runtime-adapters.js +130 -0
- package/dist/composer/runtime-adapters.js.map +1 -0
- package/dist/composer/types.d.ts +60 -0
- package/dist/composer/types.d.ts.map +1 -0
- package/dist/composer/types.js +2 -0
- package/dist/composer/types.js.map +1 -0
- package/dist/composer/use-file-search.d.ts +6 -0
- package/dist/composer/use-file-search.d.ts.map +1 -0
- package/dist/composer/use-file-search.js +53 -0
- package/dist/composer/use-file-search.js.map +1 -0
- package/dist/composer/use-mention-search.d.ts +6 -0
- package/dist/composer/use-mention-search.d.ts.map +1 -0
- package/dist/composer/use-mention-search.js +75 -0
- package/dist/composer/use-mention-search.js.map +1 -0
- package/dist/composer/use-skills.d.ts +7 -0
- package/dist/composer/use-skills.d.ts.map +1 -0
- package/dist/composer/use-skills.js +40 -0
- package/dist/composer/use-skills.js.map +1 -0
- package/dist/composer/useRealtimeVoiceMode.d.ts +179 -0
- package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -0
- package/dist/composer/useRealtimeVoiceMode.js +1657 -0
- package/dist/composer/useRealtimeVoiceMode.js.map +1 -0
- package/dist/composer/useRealtimeVoiceMode.spec.d.ts +2 -0
- package/dist/composer/useRealtimeVoiceMode.spec.d.ts.map +1 -0
- package/dist/composer/useRealtimeVoiceMode.spec.js +618 -0
- package/dist/composer/useRealtimeVoiceMode.spec.js.map +1 -0
- package/dist/composer/useVoiceDictation.d.ts +57 -0
- package/dist/composer/useVoiceDictation.d.ts.map +1 -0
- package/dist/composer/useVoiceDictation.js +1004 -0
- package/dist/composer/useVoiceDictation.js.map +1 -0
- package/dist/composer/useVoiceDictation.spec.d.ts +2 -0
- package/dist/composer/useVoiceDictation.spec.d.ts.map +1 -0
- package/dist/composer/useVoiceDictation.spec.js +32 -0
- package/dist/composer/useVoiceDictation.spec.js.map +1 -0
- package/dist/editor/RegistryBlockContext.d.ts +68 -0
- package/dist/editor/RegistryBlockContext.d.ts.map +1 -0
- package/dist/editor/RegistryBlockContext.js +10 -0
- package/dist/editor/RegistryBlockContext.js.map +1 -0
- package/dist/editor/RegistryBlockNode.d.ts +66 -0
- package/dist/editor/RegistryBlockNode.d.ts.map +1 -0
- package/dist/editor/RegistryBlockNode.js +406 -0
- package/dist/editor/RegistryBlockNode.js.map +1 -0
- package/dist/editor/RegistryBlockNode.spec.d.ts +2 -0
- package/dist/editor/RegistryBlockNode.spec.d.ts.map +1 -0
- package/dist/editor/RegistryBlockNode.spec.js +215 -0
- package/dist/editor/RegistryBlockNode.spec.js.map +1 -0
- package/dist/editor/index.d.ts +2 -0
- package/dist/editor/index.d.ts.map +1 -1
- package/dist/editor/index.js +2 -0
- package/dist/editor/index.js.map +1 -1
- package/dist/editor/registrySlashCommands.d.ts +68 -0
- package/dist/editor/registrySlashCommands.d.ts.map +1 -0
- package/dist/editor/registrySlashCommands.js +51 -0
- package/dist/editor/registrySlashCommands.js.map +1 -0
- package/package.json +32 -1
- package/src/app-shell/header-actions.tsx +84 -0
- package/src/app-shell/index.ts +1 -0
- package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
- package/src/chat-history/ChatHistoryList.tsx +469 -0
- package/src/chat-history/index.ts +7 -0
- package/src/chat-history.css +314 -0
- package/src/collab-ui/AgentPresenceChip.tsx +69 -0
- package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
- package/src/collab-ui/PresenceBar.tsx +346 -0
- package/src/collab-ui/RecentEditHighlights.tsx +225 -0
- package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
- package/src/collab-ui/agent-identity.ts +16 -0
- package/src/collab-ui/index.ts +39 -0
- package/src/collab-ui/lead-client.ts +32 -0
- package/src/collab-ui/types.spec.ts +18 -0
- package/src/collab-ui/types.ts +96 -0
- package/src/composer/AgentComposerFrame.tsx +59 -0
- package/src/composer/ComposerPlusMenu.tsx +983 -0
- package/src/composer/MentionPopover.tsx +412 -0
- package/src/composer/PastedTextChip.tsx +150 -0
- package/src/composer/PromptComposer.spec.ts +73 -0
- package/src/composer/PromptComposer.tsx +718 -0
- package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
- package/src/composer/RealtimeVoiceMode.tsx +856 -0
- package/src/composer/TiptapComposer.spec.ts +390 -0
- package/src/composer/TiptapComposer.tsx +2936 -0
- package/src/composer/VoiceButton.spec.ts +33 -0
- package/src/composer/VoiceButton.tsx +255 -0
- package/src/composer/asset-picker-url.spec.ts +56 -0
- package/src/composer/asset-picker-url.ts +80 -0
- package/src/composer/attachment-accept.spec.ts +22 -0
- package/src/composer/attachment-accept.ts +71 -0
- package/src/composer/draft-key.spec.ts +19 -0
- package/src/composer/draft-key.ts +7 -0
- package/src/composer/extensions/FileReference.tsx +55 -0
- package/src/composer/extensions/MentionReference.tsx +93 -0
- package/src/composer/extensions/SkillReference.tsx +56 -0
- package/src/composer/index.ts +79 -0
- package/src/composer/pasted-text.spec.ts +162 -0
- package/src/composer/pasted-text.ts +160 -0
- package/src/composer/prompt-attachments.spec.ts +80 -0
- package/src/composer/prompt-attachments.ts +93 -0
- package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
- package/src/composer/realtime-voice-audio-level.ts +64 -0
- package/src/composer/realtime-voice-transcript.spec.ts +95 -0
- package/src/composer/realtime-voice-transcript.ts +134 -0
- package/src/composer/runtime-adapters.tsx +343 -0
- package/src/composer/types.ts +66 -0
- package/src/composer/use-file-search.ts +64 -0
- package/src/composer/use-mention-search.ts +90 -0
- package/src/composer/use-skills.ts +45 -0
- package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
- package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
- package/src/composer/useVoiceDictation.spec.ts +45 -0
- package/src/composer/useVoiceDictation.ts +1191 -0
- package/src/context-ui/ContextMeter.tsx +101 -0
- package/src/context-ui/ContextSegmentRow.tsx +184 -0
- package/src/context-ui/ContextTreemap.tsx +125 -0
- package/src/context-ui/ContextXRayPanel.tsx +377 -0
- package/src/context-ui/format.ts +42 -0
- package/src/context-ui/index.ts +15 -0
- package/src/context-ui/types.ts +43 -0
- package/src/design-tweaks/index.ts +1 -0
- package/src/design-tweaks/visual-style-controls.tsx +1084 -0
- package/src/editor/BubbleToolbar.tsx +253 -0
- package/src/editor/CodeBlockNode.tsx +254 -0
- package/src/editor/DragHandle.spec.ts +588 -0
- package/src/editor/DragHandle.ts +1437 -0
- package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
- package/src/editor/ImageExtension.ts +306 -0
- package/src/editor/RegistryBlockContext.tsx +107 -0
- package/src/editor/RegistryBlockNode.spec.ts +279 -0
- package/src/editor/RegistryBlockNode.tsx +652 -0
- package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
- package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
- package/src/editor/RichMarkdownEditor.tsx +81 -0
- package/src/editor/RunId.ts +61 -0
- package/src/editor/SharedRichEditor.tsx +321 -0
- package/src/editor/SlashCommandMenu.tsx +310 -0
- package/src/editor/disableHistory.spec.ts +85 -0
- package/src/editor/extensions.ts +402 -0
- package/src/editor/gfmDoc.spec.ts +230 -0
- package/src/editor/gfmDoc.ts +92 -0
- package/src/editor/index.ts +93 -0
- package/src/editor/registrySlashCommands.ts +131 -0
- package/src/editor/structuralReorderUndo.spec.ts +144 -0
- package/src/editor/surgical-apply.spec.ts +279 -0
- package/src/editor/surgical-apply.ts +192 -0
- package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
- package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
- package/src/editor/useCollabReconcile.ts +589 -0
- package/src/editor.css +510 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-mobile.tsx +21 -0
- package/src/hooks/use-toast.ts +188 -0
- package/src/index.ts +8 -0
- package/src/onboarding/index.ts +1 -0
- package/src/provider.tsx +47 -0
- package/src/sharing/VisibilityBadge.spec.ts +96 -0
- package/src/sharing/VisibilityBadge.tsx +37 -0
- package/src/sharing/index.ts +4 -0
- package/src/styles.css +21 -0
- package/src/ui/accordion.tsx +56 -0
- package/src/ui/alert-dialog.tsx +141 -0
- package/src/ui/alert.tsx +59 -0
- package/src/ui/aspect-ratio.tsx +5 -0
- package/src/ui/avatar.tsx +48 -0
- package/src/ui/badge.tsx +37 -0
- package/src/ui/breadcrumb.tsx +115 -0
- package/src/ui/button.tsx +78 -0
- package/src/ui/calendar.tsx +214 -0
- package/src/ui/card.tsx +86 -0
- package/src/ui/carousel.tsx +260 -0
- package/src/ui/chart.tsx +375 -0
- package/src/ui/checkbox.tsx +28 -0
- package/src/ui/collapsible.tsx +9 -0
- package/src/ui/command.spec.tsx +51 -0
- package/src/ui/command.tsx +174 -0
- package/src/ui/context-menu.tsx +202 -0
- package/src/ui/date-picker.tsx +64 -0
- package/src/ui/dialog.spec.tsx +43 -0
- package/src/ui/dialog.tsx +146 -0
- package/src/ui/drawer.tsx +126 -0
- package/src/ui/dropdown-menu.tsx +218 -0
- package/src/ui/form.tsx +177 -0
- package/src/ui/hover-card.tsx +27 -0
- package/src/ui/index.ts +52 -0
- package/src/ui/input-otp.tsx +69 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/logo.tsx +36 -0
- package/src/ui/menubar.tsx +235 -0
- package/src/ui/navigation-menu.tsx +128 -0
- package/src/ui/pagination.tsx +121 -0
- package/src/ui/popover.tsx +44 -0
- package/src/ui/progress.tsx +26 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/resizable.tsx +43 -0
- package/src/ui/scroll-area.tsx +46 -0
- package/src/ui/select.tsx +158 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +160 -0
- package/src/ui/sidebar.tsx +777 -0
- package/src/ui/skeleton.tsx +18 -0
- package/src/ui/slider.tsx +26 -0
- package/src/ui/sonner.tsx +58 -0
- package/src/ui/spinner.tsx +17 -0
- package/src/ui/switch.tsx +27 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/tabs.tsx +53 -0
- package/src/ui/textarea.tsx +23 -0
- package/src/ui/toast.tsx +127 -0
- package/src/ui/toaster.tsx +33 -0
- package/src/ui/toggle-group.tsx +59 -0
- package/src/ui/toggle.tsx +43 -0
- package/src/ui/tooltip.tsx +53 -0
- package/src/ui/use-toast.ts +3 -0
- 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
|
+
});
|