@agent-native/toolkit 0.16.9 → 0.16.11

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 (56) hide show
  1. package/dist/collab-ui/lead-client.d.ts.map +1 -1
  2. package/dist/collab-ui/lead-client.js +11 -0
  3. package/dist/collab-ui/lead-client.js.map +1 -1
  4. package/dist/collab-ui/lead-client.spec.d.ts +2 -0
  5. package/dist/collab-ui/lead-client.spec.d.ts.map +1 -0
  6. package/dist/collab-ui/lead-client.spec.js +16 -0
  7. package/dist/collab-ui/lead-client.spec.js.map +1 -0
  8. package/dist/composer/TiptapComposer.d.ts +12 -0
  9. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  10. package/dist/composer/TiptapComposer.js +59 -18
  11. package/dist/composer/TiptapComposer.js.map +1 -1
  12. package/dist/composer/TiptapComposer.spec.js +31 -1
  13. package/dist/composer/TiptapComposer.spec.js.map +1 -1
  14. package/dist/design-tweaks/scrub-input.d.ts.map +1 -1
  15. package/dist/design-tweaks/scrub-input.js +3 -2
  16. package/dist/design-tweaks/scrub-input.js.map +1 -1
  17. package/dist/design-tweaks/visual-style-controls.spec.js +19 -0
  18. package/dist/design-tweaks/visual-style-controls.spec.js.map +1 -1
  19. package/dist/editor/RegistryBlockContext.d.ts +4 -0
  20. package/dist/editor/RegistryBlockContext.d.ts.map +1 -1
  21. package/dist/editor/RegistryBlockContext.js.map +1 -1
  22. package/dist/editor/RegistryBlockNode.d.ts +13 -0
  23. package/dist/editor/RegistryBlockNode.d.ts.map +1 -1
  24. package/dist/editor/RegistryBlockNode.js +47 -20
  25. package/dist/editor/RegistryBlockNode.js.map +1 -1
  26. package/dist/editor/RegistryBlockNode.spec.js +61 -1
  27. package/dist/editor/RegistryBlockNode.spec.js.map +1 -1
  28. package/dist/editor/useCollabReconcile.concurrent.spec.js +63 -0
  29. package/dist/editor/useCollabReconcile.concurrent.spec.js.map +1 -1
  30. package/dist/editor/useCollabReconcile.d.ts.map +1 -1
  31. package/dist/editor/useCollabReconcile.js +15 -2
  32. package/dist/editor/useCollabReconcile.js.map +1 -1
  33. package/dist/ui/index.d.ts +1 -0
  34. package/dist/ui/index.d.ts.map +1 -1
  35. package/dist/ui/index.js +1 -0
  36. package/dist/ui/index.js.map +1 -1
  37. package/dist/ui/pointer-lock.d.ts +2 -0
  38. package/dist/ui/pointer-lock.d.ts.map +1 -0
  39. package/dist/ui/pointer-lock.js +22 -0
  40. package/dist/ui/pointer-lock.js.map +1 -0
  41. package/dist/ui/tooltip.js +1 -1
  42. package/package.json +1 -1
  43. package/src/collab-ui/lead-client.spec.ts +20 -0
  44. package/src/collab-ui/lead-client.ts +17 -1
  45. package/src/composer/TiptapComposer.spec.ts +40 -0
  46. package/src/composer/TiptapComposer.tsx +182 -75
  47. package/src/design-tweaks/scrub-input.tsx +3 -2
  48. package/src/design-tweaks/visual-style-controls.spec.tsx +37 -0
  49. package/src/editor/RegistryBlockContext.tsx +4 -0
  50. package/src/editor/RegistryBlockNode.spec.ts +78 -0
  51. package/src/editor/RegistryBlockNode.tsx +106 -19
  52. package/src/editor/useCollabReconcile.concurrent.spec.ts +75 -0
  53. package/src/editor/useCollabReconcile.ts +19 -2
  54. package/src/ui/index.ts +1 -0
  55. package/src/ui/pointer-lock.ts +21 -0
  56. package/src/ui/tooltip.tsx +1 -1
@@ -80,6 +80,55 @@ function clickedInteractiveChild(target: HTMLElement) {
80
80
  return !!blockNode && !!editable && blockNode.contains(editable);
81
81
  }
82
82
 
83
+ export function selectRegistryBlockNode({
84
+ editable,
85
+ allowInteractiveChild,
86
+ target,
87
+ getPos,
88
+ view,
89
+ preventDefault,
90
+ stopPropagation,
91
+ }: {
92
+ editable: boolean;
93
+ allowInteractiveChild: boolean;
94
+ target: EventTarget | null;
95
+ getPos: (() => number | undefined) | boolean;
96
+ view: NodeViewProps["editor"]["view"];
97
+ preventDefault: () => void;
98
+ stopPropagation: () => void;
99
+ }): boolean {
100
+ if (!editable) return false;
101
+ if (
102
+ !allowInteractiveChild &&
103
+ target instanceof HTMLElement &&
104
+ clickedInteractiveChild(target)
105
+ )
106
+ return false;
107
+ if (
108
+ allowInteractiveChild &&
109
+ target instanceof HTMLElement &&
110
+ target.closest("pre")
111
+ )
112
+ return false;
113
+ const pos = typeof getPos === "function" ? getPos() : null;
114
+ if (typeof pos !== "number") return false;
115
+ try {
116
+ preventDefault();
117
+ stopPropagation();
118
+ view.dispatch(
119
+ view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)),
120
+ );
121
+ view.focus();
122
+ return true;
123
+ } catch (error) {
124
+ // A node can disappear between the mousedown and selection dispatch during
125
+ // reconciliation. Keep that expected stale-position race recoverable, but
126
+ // do not hide unrelated editor failures.
127
+ if (error instanceof RangeError) return false;
128
+ throw error;
129
+ }
130
+ }
131
+
83
132
  /* -------------------------------------------------------------------------- */
84
133
  /* B. RegistryBlockNodeView (React) */
85
134
  /* -------------------------------------------------------------------------- */
@@ -139,6 +188,21 @@ export function RegistryBlockNodeView(props: NodeViewProps) {
139
188
  (sideMap?.notionSync ?? false) &&
140
189
  (sideMap?.isNotionIncompatibleType?.(blockType) ?? false);
141
190
 
191
+ const selectNode = (
192
+ event: ReactMouseEvent<HTMLElement>,
193
+ allowInteractiveChild = false,
194
+ ) => {
195
+ selectRegistryBlockNode({
196
+ editable,
197
+ allowInteractiveChild,
198
+ target: event.target,
199
+ getPos: props.getPos,
200
+ view: props.editor.view,
201
+ preventDefault: () => event.preventDefault(),
202
+ stopPropagation: () => event.stopPropagation(),
203
+ });
204
+ };
205
+
142
206
  // The block data isn't in the side-map yet (e.g. a freshly inserted node whose
143
207
  // store entry hasn't been seeded). Render a graceful placeholder.
144
208
  if (!block) {
@@ -155,25 +219,23 @@ export function RegistryBlockNodeView(props: NodeViewProps) {
155
219
  );
156
220
  }
157
221
 
158
- const selectNode = (event: ReactMouseEvent<HTMLElement>) => {
159
- if (!editable) return;
160
- const target = event.target;
161
- if (target instanceof HTMLElement && clickedInteractiveChild(target))
162
- return;
163
- const pos = typeof props.getPos === "function" ? props.getPos() : null;
164
- if (typeof pos !== "number") return;
165
- try {
166
- event.preventDefault();
167
- event.stopPropagation();
168
- const { view } = props.editor;
169
- view.dispatch(
170
- view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)),
171
- );
172
- view.focus();
173
- } catch {
174
- // Ignore stale positions during React/ProseMirror reconciliation.
175
- }
176
- };
222
+ if (block.loadError) {
223
+ return (
224
+ <NodeViewWrapper
225
+ className="plan-block-node"
226
+ data-block-id={blockId}
227
+ onMouseDownCapture={(event: ReactMouseEvent<HTMLElement>) =>
228
+ selectNode(event, true)
229
+ }
230
+ >
231
+ <RegistryBlockLoadErrorView
232
+ message={block.loadError.message}
233
+ rawSource={block.loadError.rawSource}
234
+ />
235
+ </NodeViewWrapper>
236
+ );
237
+ }
238
+
177
239
  const updateShellHover = (event: ReactMouseEvent<HTMLElement>) => {
178
240
  const target = event.target;
179
241
  setShellHovered(
@@ -277,6 +339,31 @@ export function RegistryBlockNodeView(props: NodeViewProps) {
277
339
  );
278
340
  }
279
341
 
342
+ export function RegistryBlockLoadErrorView({
343
+ message,
344
+ rawSource,
345
+ }: {
346
+ message: string;
347
+ rawSource?: string;
348
+ }) {
349
+ return (
350
+ <div
351
+ contentEditable={false}
352
+ data-plan-interactive
353
+ className="plan-block-node__load-error space-y-2 rounded-md border border-border px-3 py-2 text-sm"
354
+ >
355
+ {rawSource ? (
356
+ <pre className="overflow-auto whitespace-pre-wrap font-mono text-xs text-foreground">
357
+ {rawSource}
358
+ </pre>
359
+ ) : null}
360
+ <p role="alert" className="text-muted-foreground">
361
+ {message}
362
+ </p>
363
+ </div>
364
+ );
365
+ }
366
+
280
367
  export function LegacyJsonEditSurface({
281
368
  block,
282
369
  blockType,
@@ -746,6 +746,81 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
746
746
  expect(getEditorMarkdown(captured.editor!)).toBe("# Doc updated by agent");
747
747
  });
748
748
 
749
+ it("persists a non-lead client's own local edit to a nonempty shared document", async () => {
750
+ // Losing the lead election only bars this client from SEEDING. Its own
751
+ // typing still has to reach the app's persist path, or the text lives in
752
+ // the CRDT alone and the next lead mount overwrites it with the SQL body.
753
+ const persistedYdoc = new Y.Doc();
754
+ const persistedEditor = new CoreEditor({
755
+ extensions: createRichMarkdownExtensions({
756
+ dialect: "gfm",
757
+ ydoc: persistedYdoc,
758
+ }),
759
+ });
760
+ persistedEditor.commands.setContent("shared body");
761
+ const liveYdoc = new Y.Doc();
762
+ Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote");
763
+ persistedEditor.destroy();
764
+ persistedYdoc.destroy();
765
+
766
+ liveYdoc.clientID = 500;
767
+ const awareness = new Awareness(liveYdoc);
768
+ awareness
769
+ .getStates()
770
+ .set(1, { user: { name: "Lead peer" }, visible: true });
771
+
772
+ const emitted: string[] = [];
773
+ let capturedEditor: Editor | null = null;
774
+
775
+ function Probe() {
776
+ const guardsRef = React.useRef<ReturnType<
777
+ typeof useCollabReconcile
778
+ > | null>(null);
779
+ const editor = useEditor({
780
+ extensions: createRichMarkdownExtensions({
781
+ dialect: "gfm",
782
+ ydoc: liveYdoc,
783
+ }),
784
+ onUpdate: ({ editor, transaction }) => {
785
+ const guards = guardsRef.current;
786
+ if (!guards || guards.shouldIgnoreUpdate(transaction)) return;
787
+ const markdown = getEditorMarkdown(editor);
788
+ if (!guards.registerEmitted(markdown)) return;
789
+ emitted.push(markdown);
790
+ },
791
+ });
792
+ capturedEditor = editor;
793
+ guardsRef.current = useCollabReconcile({
794
+ editor,
795
+ ydoc: liveYdoc,
796
+ awareness,
797
+ collabSynced: true,
798
+ value: "shared body",
799
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
800
+ editable: true,
801
+ });
802
+ return React.createElement("div", null);
803
+ }
804
+
805
+ act(() => root.render(React.createElement(Probe)));
806
+ await flush();
807
+
808
+ act(() => {
809
+ capturedEditor!.view.dispatch(
810
+ capturedEditor!.state.tr
811
+ .insertText(
812
+ " plus my edit",
813
+ capturedEditor!.state.doc.content.size - 1,
814
+ )
815
+ .setMeta("uiEvent", "input"),
816
+ );
817
+ });
818
+
819
+ expect(emitted.at(-1)).toContain("plus my edit");
820
+ awareness.destroy();
821
+ liveYdoc.destroy();
822
+ });
823
+
749
824
  it("does not reapply a partial save echo while an external editor control owns focus", async () => {
750
825
  const { captured, Harness } = makeHarness();
751
826
 
@@ -298,7 +298,15 @@ export function useCollabReconcile({
298
298
  awareness.getStates().forEach((state, clientId) => {
299
299
  if (clientId === ydoc.clientID) return; // self
300
300
  if (clientId === AGENT_CLIENT_ID) return; // agent isn't a Yjs editor
301
- const s = state as { user?: unknown; visible?: boolean };
301
+ const s = state as {
302
+ user?: unknown;
303
+ visible?: boolean;
304
+ canFlushDocument?: boolean;
305
+ };
306
+ // A read-only viewer binds no Y.Doc, so counting it as a peer lets
307
+ // stale CRDT content stand in for live collaboration and be written
308
+ // back over canonical SQL — resurrecting deleted content.
309
+ if (s?.canFlushDocument === false) return;
302
310
  if (s && s.user && s.visible !== false) peers += 1;
303
311
  });
304
312
  peerCountRef.current = peers;
@@ -362,7 +370,16 @@ export function useCollabReconcile({
362
370
  emptySnapshotDecisionPendingRef.current = false;
363
371
  };
364
372
  }
365
- if (!isLeadClient) return;
373
+ // A non-lead client must never seed (two clients inserting the same content
374
+ // duplicates it), but `seededRef` also gates persistence and reconcile — so
375
+ // release it here anyway, or this client's own typing is dropped before it
376
+ // ever reaches SQL while its peers still see it through Yjs.
377
+ if (!isLeadClient) {
378
+ const releaseTimer = setTimeout(() => {
379
+ seededRef.current = true;
380
+ }, 0);
381
+ return () => clearTimeout(releaseTimer);
382
+ }
366
383
  let cancelled = false;
367
384
  // Defer via a timer task (NOT a microtask — microtasks can still run
368
385
  // inside React's commit and trigger flushSync-from-lifecycle warnings).
package/src/ui/index.ts CHANGED
@@ -27,6 +27,7 @@ export * from "./logo.js";
27
27
  export * from "./menubar.js";
28
28
  export * from "./navigation-menu.js";
29
29
  export * from "./pagination.js";
30
+ export * from "./pointer-lock.js";
30
31
  export * from "./popover.js";
31
32
  export * from "./progress.js";
32
33
  export * from "./radio-group.js";
@@ -0,0 +1,21 @@
1
+ // Radix's DismissableLayer (used by Dialog, Sheet, AlertDialog, Select, ...)
2
+ // tracks every mounted layer that disables outside pointer events in one
3
+ // shared, module-level registry: the first layer to mount saves
4
+ // `document.body.style.pointerEvents` and sets it to "none", and the last
5
+ // layer to unregister restores it. When a nested layer (e.g. a Select inside
6
+ // a Sheet) is still unwinding its own close animation at the moment an outer
7
+ // layer closes, the registry can end up with nothing left to trigger that
8
+ // restore, and `pointerEvents` sticks at "none" — freezing the whole page
9
+ // until reload. Defer any state change that opens a follow-up layer (another
10
+ // dialog) or runs a mutation gated on the previous layer being fully closed
11
+ // until pointer events are confirmed unlocked.
12
+ export function afterBodyPointerUnlock(callback: () => void) {
13
+ const run = () => {
14
+ if (document.body.style.pointerEvents === "none") {
15
+ window.requestAnimationFrame(run);
16
+ return;
17
+ }
18
+ callback();
19
+ };
20
+ window.requestAnimationFrame(run);
21
+ }
@@ -36,7 +36,7 @@ const TooltipContent = React.forwardRef<
36
36
  sideOffset={sideOffset}
37
37
  data-agent-native-tooltip="true"
38
38
  className={cn(
39
- "z-[300] overflow-hidden rounded-md border border-border bg-popover px-2 py-1 text-[11px] text-foreground shadow-md origin-[var(--radix-tooltip-content-transform-origin)] data-[state=delayed-open]:animate-in data-[state=closed]:animate-out data-[state=delayed-open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=delayed-open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=delayed-open]:duration-150 data-[state=closed]:duration-100 motion-reduce:data-[state=delayed-open]:zoom-in-100 motion-reduce:data-[state=closed]:zoom-out-100",
39
+ "z-[500] overflow-hidden rounded-md border border-border bg-popover px-2 py-1 text-[11px] text-foreground shadow-md origin-[var(--radix-tooltip-content-transform-origin)] data-[state=delayed-open]:animate-in data-[state=closed]:animate-out data-[state=delayed-open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=delayed-open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=delayed-open]:duration-150 data-[state=closed]:duration-100 motion-reduce:data-[state=delayed-open]:zoom-in-100 motion-reduce:data-[state=closed]:zoom-out-100",
40
40
  className,
41
41
  )}
42
42
  {...props}