@agent-native/toolkit 0.16.10 → 0.16.12
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/dist/collab-ui/lead-client.d.ts.map +1 -1
- package/dist/collab-ui/lead-client.js +11 -0
- package/dist/collab-ui/lead-client.js.map +1 -1
- package/dist/collab-ui/lead-client.spec.d.ts +2 -0
- package/dist/collab-ui/lead-client.spec.d.ts.map +1 -0
- package/dist/collab-ui/lead-client.spec.js +16 -0
- package/dist/collab-ui/lead-client.spec.js.map +1 -0
- package/dist/composer/TiptapComposer.d.ts +12 -0
- package/dist/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.js +59 -18
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/dist/composer/TiptapComposer.spec.js +31 -1
- package/dist/composer/TiptapComposer.spec.js.map +1 -1
- package/dist/design-tweaks/scrub-input.d.ts.map +1 -1
- package/dist/design-tweaks/scrub-input.js +3 -2
- package/dist/design-tweaks/scrub-input.js.map +1 -1
- package/dist/design-tweaks/visual-style-controls.spec.js +19 -0
- package/dist/design-tweaks/visual-style-controls.spec.js.map +1 -1
- package/dist/editor/RegistryBlockContext.d.ts +4 -0
- package/dist/editor/RegistryBlockContext.d.ts.map +1 -1
- package/dist/editor/RegistryBlockContext.js.map +1 -1
- package/dist/editor/RegistryBlockNode.d.ts +13 -0
- package/dist/editor/RegistryBlockNode.d.ts.map +1 -1
- package/dist/editor/RegistryBlockNode.js +47 -20
- package/dist/editor/RegistryBlockNode.js.map +1 -1
- package/dist/editor/RegistryBlockNode.spec.js +61 -1
- package/dist/editor/RegistryBlockNode.spec.js.map +1 -1
- package/dist/editor/useCollabReconcile.concurrent.spec.js +63 -0
- package/dist/editor/useCollabReconcile.concurrent.spec.js.map +1 -1
- package/dist/editor/useCollabReconcile.d.ts.map +1 -1
- package/dist/editor/useCollabReconcile.js +15 -2
- package/dist/editor/useCollabReconcile.js.map +1 -1
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/pointer-lock.d.ts +2 -0
- package/dist/ui/pointer-lock.d.ts.map +1 -0
- package/dist/ui/pointer-lock.js +22 -0
- package/dist/ui/pointer-lock.js.map +1 -0
- package/dist/ui/tooltip.js +1 -1
- package/package.json +1 -1
- package/src/collab-ui/lead-client.spec.ts +20 -0
- package/src/collab-ui/lead-client.ts +17 -1
- package/src/composer/TiptapComposer.spec.ts +40 -0
- package/src/composer/TiptapComposer.tsx +182 -75
- package/src/design-tweaks/scrub-input.tsx +3 -2
- package/src/design-tweaks/visual-style-controls.spec.tsx +37 -0
- package/src/editor/RegistryBlockContext.tsx +4 -0
- package/src/editor/RegistryBlockNode.spec.ts +78 -0
- package/src/editor/RegistryBlockNode.tsx +106 -19
- package/src/editor/useCollabReconcile.concurrent.spec.ts +75 -0
- package/src/editor/useCollabReconcile.ts +19 -2
- package/src/ui/index.ts +1 -0
- package/src/ui/pointer-lock.ts +21 -0
- 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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/src/ui/tooltip.tsx
CHANGED
|
@@ -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-[
|
|
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}
|