@agent-native/toolkit 0.10.4 → 0.10.6
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 +8 -1
- package/agent-native.eject.json +1 -1
- package/dist/app-shell/index.d.ts +1 -0
- package/dist/app-shell/index.d.ts.map +1 -1
- package/dist/app-shell/index.js +1 -0
- package/dist/app-shell/index.js.map +1 -1
- package/dist/app-shell/sidebar-footer-actions.d.ts +15 -0
- package/dist/app-shell/sidebar-footer-actions.d.ts.map +1 -0
- package/dist/app-shell/sidebar-footer-actions.js +10 -0
- package/dist/app-shell/sidebar-footer-actions.js.map +1 -0
- package/dist/app-shell/sidebar-footer-actions.spec.d.ts +2 -0
- package/dist/app-shell/sidebar-footer-actions.spec.d.ts.map +1 -0
- package/dist/app-shell/sidebar-footer-actions.spec.js +35 -0
- package/dist/app-shell/sidebar-footer-actions.spec.js.map +1 -0
- package/dist/chat-history.css +52 -1
- package/dist/design-system/default-adapter.js +1 -1
- package/dist/design-system/default-adapter.js.map +1 -1
- package/dist/editor/useCollabReconcile.concurrent.spec.js +240 -1
- package/dist/editor/useCollabReconcile.concurrent.spec.js.map +1 -1
- package/dist/editor/useCollabReconcile.d.ts.map +1 -1
- package/dist/editor/useCollabReconcile.js +77 -13
- package/dist/editor/useCollabReconcile.js.map +1 -1
- package/dist/ui/button.d.ts.map +1 -1
- package/dist/ui/button.js +1 -1
- package/dist/ui/button.js.map +1 -1
- package/dist/ui/card.js +1 -1
- package/dist/ui/card.js.map +1 -1
- package/dist/ui/table.d.ts.map +1 -1
- package/dist/ui/table.js +4 -4
- package/dist/ui/table.js.map +1 -1
- package/package.json +1 -1
- package/src/app-shell/index.ts +1 -0
- package/src/app-shell/sidebar-footer-actions.spec.tsx +65 -0
- package/src/app-shell/sidebar-footer-actions.tsx +48 -0
- package/src/chat-history.css +52 -1
- package/src/design-system/default-adapter.tsx +1 -1
- package/src/editor/useCollabReconcile.concurrent.spec.ts +270 -1
- package/src/editor/useCollabReconcile.ts +83 -15
- package/src/ui/button.tsx +1 -2
- package/src/ui/card.tsx +1 -1
- package/src/ui/table.tsx +4 -11
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
+
import { Editor as CoreEditor } from "@tiptap/core";
|
|
3
4
|
import { useEditor, type Editor } from "@tiptap/react";
|
|
4
5
|
import React, { act } from "react";
|
|
5
6
|
import { createRoot, type Root } from "react-dom/client";
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { Awareness } from "y-protocols/awareness";
|
|
9
|
+
import * as Y from "yjs";
|
|
7
10
|
|
|
8
11
|
import { createRichMarkdownExtensions } from "./RichMarkdownEditor.js";
|
|
9
12
|
import { useCollabReconcile, getEditorMarkdown } from "./useCollabReconcile.js";
|
|
@@ -38,6 +41,7 @@ beforeEach(() => {
|
|
|
38
41
|
|
|
39
42
|
afterEach(() => {
|
|
40
43
|
act(() => root.unmount());
|
|
44
|
+
vi.useRealTimers();
|
|
41
45
|
container.remove();
|
|
42
46
|
});
|
|
43
47
|
|
|
@@ -330,6 +334,37 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
|
|
|
330
334
|
expect(results).toEqual([true]);
|
|
331
335
|
});
|
|
332
336
|
|
|
337
|
+
it("allows the first user edit after an authoritative empty doc finishes loading", async () => {
|
|
338
|
+
let shouldIgnoreUpdate:
|
|
339
|
+
| ((transaction: Editor["state"]["tr"]) => boolean)
|
|
340
|
+
| null = null;
|
|
341
|
+
let editor: Editor | null = null;
|
|
342
|
+
|
|
343
|
+
function Probe() {
|
|
344
|
+
editor = useEditor({
|
|
345
|
+
extensions: createRichMarkdownExtensions({ dialect: "gfm" }),
|
|
346
|
+
content: "",
|
|
347
|
+
});
|
|
348
|
+
const fakeYdoc = { clientID: 1, getXmlFragment: () => ({ length: 0 }) };
|
|
349
|
+
const guards = useCollabReconcile({
|
|
350
|
+
editor,
|
|
351
|
+
ydoc: fakeYdoc as never,
|
|
352
|
+
collabSynced: true,
|
|
353
|
+
value: "",
|
|
354
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
355
|
+
editable: true,
|
|
356
|
+
});
|
|
357
|
+
shouldIgnoreUpdate = guards.shouldIgnoreUpdate;
|
|
358
|
+
return React.createElement("div", null);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
act(() => root.render(React.createElement(Probe)));
|
|
362
|
+
|
|
363
|
+
expect(editor).not.toBeNull();
|
|
364
|
+
expect(shouldIgnoreUpdate).not.toBeNull();
|
|
365
|
+
expect(shouldIgnoreUpdate!(editor!.state.tr)).toBe(false);
|
|
366
|
+
});
|
|
367
|
+
|
|
333
368
|
it("refuses to persist an empty doc in collab mode (registerEmitted guard)", async () => {
|
|
334
369
|
// Directly exercise the guard contract: in collab mode an empty markdown
|
|
335
370
|
// string must not be registered/persisted (would clobber stored content
|
|
@@ -399,6 +434,240 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
|
|
|
399
434
|
expect(setContentValues).toEqual(["second seed"]);
|
|
400
435
|
});
|
|
401
436
|
|
|
437
|
+
it("does not seed beside persisted Y.Doc content projected during initial sync", async () => {
|
|
438
|
+
const persistedYdoc = new Y.Doc();
|
|
439
|
+
const persistedEditor = new CoreEditor({
|
|
440
|
+
extensions: createRichMarkdownExtensions({
|
|
441
|
+
dialect: "gfm",
|
|
442
|
+
ydoc: persistedYdoc,
|
|
443
|
+
}),
|
|
444
|
+
});
|
|
445
|
+
persistedEditor.commands.setContent("persisted collab body");
|
|
446
|
+
const persistedUpdate = Y.encodeStateAsUpdate(persistedYdoc);
|
|
447
|
+
persistedEditor.destroy();
|
|
448
|
+
persistedYdoc.destroy();
|
|
449
|
+
|
|
450
|
+
const liveYdoc = new Y.Doc();
|
|
451
|
+
const setContentValues: string[] = [];
|
|
452
|
+
let capturedEditor: Editor | null = null;
|
|
453
|
+
|
|
454
|
+
function Probe({ collabSynced }: { collabSynced: boolean }) {
|
|
455
|
+
const editor = useEditor({
|
|
456
|
+
extensions: createRichMarkdownExtensions({
|
|
457
|
+
dialect: "gfm",
|
|
458
|
+
ydoc: liveYdoc,
|
|
459
|
+
}),
|
|
460
|
+
});
|
|
461
|
+
capturedEditor = editor;
|
|
462
|
+
useCollabReconcile({
|
|
463
|
+
editor,
|
|
464
|
+
ydoc: liveYdoc,
|
|
465
|
+
collabSynced,
|
|
466
|
+
value: "persisted collab body",
|
|
467
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
468
|
+
editable: true,
|
|
469
|
+
setContent: (_editor, nextValue) => {
|
|
470
|
+
setContentValues.push(nextValue);
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
return React.createElement("div", null);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
act(() => root.render(React.createElement(Probe, { collabSynced: false })));
|
|
477
|
+
act(() => Y.applyUpdate(liveYdoc, persistedUpdate, "remote"));
|
|
478
|
+
act(() => root.render(React.createElement(Probe, { collabSynced: true })));
|
|
479
|
+
await flush();
|
|
480
|
+
|
|
481
|
+
expect(getEditorMarkdown(capturedEditor!)).toBe("persisted collab body");
|
|
482
|
+
expect(setContentValues).toEqual([]);
|
|
483
|
+
expect(
|
|
484
|
+
getEditorMarkdown(capturedEditor!).match(/persisted collab body/g),
|
|
485
|
+
).toHaveLength(1);
|
|
486
|
+
liveYdoc.destroy();
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("adopts a nonempty synced Y.Doc instead of reconciling an empty SQL snapshot", async () => {
|
|
490
|
+
vi.useFakeTimers();
|
|
491
|
+
const persistedYdoc = new Y.Doc();
|
|
492
|
+
const persistedEditor = new CoreEditor({
|
|
493
|
+
extensions: createRichMarkdownExtensions({
|
|
494
|
+
dialect: "gfm",
|
|
495
|
+
ydoc: persistedYdoc,
|
|
496
|
+
}),
|
|
497
|
+
});
|
|
498
|
+
persistedEditor.commands.setContent("live collaborator body");
|
|
499
|
+
const liveYdoc = new Y.Doc();
|
|
500
|
+
Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote");
|
|
501
|
+
persistedEditor.destroy();
|
|
502
|
+
persistedYdoc.destroy();
|
|
503
|
+
|
|
504
|
+
const awareness = new Awareness(liveYdoc);
|
|
505
|
+
const setContentValues: string[] = [];
|
|
506
|
+
let capturedEditor: Editor | null = null;
|
|
507
|
+
|
|
508
|
+
function Probe() {
|
|
509
|
+
const editor = useEditor({
|
|
510
|
+
extensions: createRichMarkdownExtensions({
|
|
511
|
+
dialect: "gfm",
|
|
512
|
+
ydoc: liveYdoc,
|
|
513
|
+
}),
|
|
514
|
+
});
|
|
515
|
+
capturedEditor = editor;
|
|
516
|
+
useCollabReconcile({
|
|
517
|
+
editor,
|
|
518
|
+
ydoc: liveYdoc,
|
|
519
|
+
awareness,
|
|
520
|
+
collabSynced: true,
|
|
521
|
+
value: "",
|
|
522
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
523
|
+
editable: true,
|
|
524
|
+
setContent: (_editor, nextValue) => {
|
|
525
|
+
setContentValues.push(nextValue);
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
return React.createElement("div", null);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
act(() => root.render(React.createElement(Probe)));
|
|
532
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
533
|
+
// The document state can finish syncing before the first awareness poll.
|
|
534
|
+
// Publish the active peer after mount to cover that real transport order.
|
|
535
|
+
act(() => {
|
|
536
|
+
awareness.getStates().set(4_294_967_295, {
|
|
537
|
+
user: { name: "Active peer" },
|
|
538
|
+
visible: true,
|
|
539
|
+
});
|
|
540
|
+
awareness.emit("change", [
|
|
541
|
+
{ added: [4_294_967_295], updated: [], removed: [] },
|
|
542
|
+
"remote",
|
|
543
|
+
]);
|
|
544
|
+
});
|
|
545
|
+
await act(async () => vi.advanceTimersByTimeAsync(2500));
|
|
546
|
+
|
|
547
|
+
expect(getEditorMarkdown(capturedEditor!)).toBe("live collaborator body");
|
|
548
|
+
expect(setContentValues).toEqual([]);
|
|
549
|
+
vi.useRealTimers();
|
|
550
|
+
awareness.destroy();
|
|
551
|
+
liveYdoc.destroy();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("lets canonical empty SQL clear stale persisted Y.Doc content with no active peer", async () => {
|
|
555
|
+
const persistedYdoc = new Y.Doc();
|
|
556
|
+
const persistedEditor = new CoreEditor({
|
|
557
|
+
extensions: createRichMarkdownExtensions({
|
|
558
|
+
dialect: "gfm",
|
|
559
|
+
ydoc: persistedYdoc,
|
|
560
|
+
}),
|
|
561
|
+
});
|
|
562
|
+
persistedEditor.commands.setContent("stale persisted body");
|
|
563
|
+
const liveYdoc = new Y.Doc();
|
|
564
|
+
Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote");
|
|
565
|
+
persistedEditor.destroy();
|
|
566
|
+
persistedYdoc.destroy();
|
|
567
|
+
|
|
568
|
+
const setContentValues: string[] = [];
|
|
569
|
+
let capturedEditor: Editor | null = null;
|
|
570
|
+
const awareness = new Awareness(liveYdoc);
|
|
571
|
+
|
|
572
|
+
function Probe() {
|
|
573
|
+
const editor = useEditor({
|
|
574
|
+
extensions: createRichMarkdownExtensions({
|
|
575
|
+
dialect: "gfm",
|
|
576
|
+
ydoc: liveYdoc,
|
|
577
|
+
}),
|
|
578
|
+
});
|
|
579
|
+
capturedEditor = editor;
|
|
580
|
+
useCollabReconcile({
|
|
581
|
+
editor,
|
|
582
|
+
ydoc: liveYdoc,
|
|
583
|
+
awareness,
|
|
584
|
+
collabSynced: true,
|
|
585
|
+
value: "",
|
|
586
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
587
|
+
editable: true,
|
|
588
|
+
setContent: (editorToClear, nextValue) => {
|
|
589
|
+
setContentValues.push(nextValue);
|
|
590
|
+
editorToClear.commands.setContent(nextValue);
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
return React.createElement("div", null);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
vi.useFakeTimers();
|
|
597
|
+
act(() => root.render(React.createElement(Probe)));
|
|
598
|
+
await act(async () => vi.advanceTimersByTimeAsync(2499));
|
|
599
|
+
expect(setContentValues).toEqual([]);
|
|
600
|
+
await act(async () => vi.advanceTimersByTimeAsync(51));
|
|
601
|
+
|
|
602
|
+
expect(setContentValues).toContain("");
|
|
603
|
+
expect(getEditorMarkdown(capturedEditor!)).toBe("");
|
|
604
|
+
vi.useRealTimers();
|
|
605
|
+
awareness.destroy();
|
|
606
|
+
liveYdoc.destroy();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("preserves and permits a first local edit during the awareness settle window", async () => {
|
|
610
|
+
const persistedYdoc = new Y.Doc();
|
|
611
|
+
const persistedEditor = new CoreEditor({
|
|
612
|
+
extensions: createRichMarkdownExtensions({
|
|
613
|
+
dialect: "gfm",
|
|
614
|
+
ydoc: persistedYdoc,
|
|
615
|
+
}),
|
|
616
|
+
});
|
|
617
|
+
persistedEditor.commands.setContent("stale persisted body");
|
|
618
|
+
const liveYdoc = new Y.Doc();
|
|
619
|
+
Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote");
|
|
620
|
+
persistedEditor.destroy();
|
|
621
|
+
persistedYdoc.destroy();
|
|
622
|
+
|
|
623
|
+
const awareness = new Awareness(liveYdoc);
|
|
624
|
+
let capturedEditor: Editor | null = null;
|
|
625
|
+
let guards: ReturnType<typeof useCollabReconcile> | null = null;
|
|
626
|
+
const setContentValues: string[] = [];
|
|
627
|
+
|
|
628
|
+
function Probe() {
|
|
629
|
+
const editor = useEditor({
|
|
630
|
+
extensions: createRichMarkdownExtensions({
|
|
631
|
+
dialect: "gfm",
|
|
632
|
+
ydoc: liveYdoc,
|
|
633
|
+
}),
|
|
634
|
+
});
|
|
635
|
+
capturedEditor = editor;
|
|
636
|
+
guards = useCollabReconcile({
|
|
637
|
+
editor,
|
|
638
|
+
ydoc: liveYdoc,
|
|
639
|
+
awareness,
|
|
640
|
+
collabSynced: true,
|
|
641
|
+
value: "",
|
|
642
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
643
|
+
editable: true,
|
|
644
|
+
setContent: (editorToSet, nextValue) => {
|
|
645
|
+
setContentValues.push(nextValue);
|
|
646
|
+
editorToSet.commands.setContent(nextValue);
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
return React.createElement("div", null);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
vi.useFakeTimers();
|
|
653
|
+
act(() => root.render(React.createElement(Probe)));
|
|
654
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
655
|
+
|
|
656
|
+
expect(guards).not.toBeNull();
|
|
657
|
+
expect(capturedEditor).not.toBeNull();
|
|
658
|
+
expect(guards!.shouldIgnoreUpdate(capturedEditor!.state.tr)).toBe(false);
|
|
659
|
+
expect(guards!.registerEmitted("first local edit")).toBe(true);
|
|
660
|
+
act(() => capturedEditor!.commands.setContent("first local edit"));
|
|
661
|
+
|
|
662
|
+
await act(async () => vi.advanceTimersByTimeAsync(2500));
|
|
663
|
+
expect(getEditorMarkdown(capturedEditor!)).toBe("first local edit");
|
|
664
|
+
expect(setContentValues).toEqual([]);
|
|
665
|
+
|
|
666
|
+
vi.useRealTimers();
|
|
667
|
+
awareness.destroy();
|
|
668
|
+
liveYdoc.destroy();
|
|
669
|
+
});
|
|
670
|
+
|
|
402
671
|
it("applies a genuinely newer external value once the user is no longer focused", async () => {
|
|
403
672
|
const { captured, Harness } = makeHarness();
|
|
404
673
|
|
|
@@ -33,6 +33,11 @@ export function getEditorMarkdown(editor: Editor): string {
|
|
|
33
33
|
* is identical, so skipping it is safe by construction.
|
|
34
34
|
*/
|
|
35
35
|
const EMITTED_RING_MAX = 16;
|
|
36
|
+
// The hosted awareness transport polls every 2s when its SSE gateway cannot
|
|
37
|
+
// forward presence. Give that first snapshot one poll plus margin before an
|
|
38
|
+
// empty SQL value is allowed to clear a nonempty Y.Doc. This is the same settle
|
|
39
|
+
// window used below when a known peer may deliver an edit through Yjs.
|
|
40
|
+
const PEER_SETTLE_MS = 2500;
|
|
36
41
|
function pushEmittedRing(ring: string[], value: string): void {
|
|
37
42
|
if (!value) return;
|
|
38
43
|
if (ring[ring.length - 1] === value) return;
|
|
@@ -264,6 +269,11 @@ export function useCollabReconcile({
|
|
|
264
269
|
// arrives via Yjs, so external markdown reconcile must defer (avoid applying
|
|
265
270
|
// the same change through both Yjs and setContent).
|
|
266
271
|
const peerCountRef = useRef(0);
|
|
272
|
+
// Briefly gates the first empty-SQL reconcile while Collaboration projects
|
|
273
|
+
// a nonempty fragment. `seededRef` is still released immediately so a real
|
|
274
|
+
// first keystroke can persist; this ref only postpones the ambiguous clear
|
|
275
|
+
// decision until we can distinguish an active/local edit from stale CRDT.
|
|
276
|
+
const emptySnapshotDecisionPendingRef = useRef(false);
|
|
267
277
|
useEffect(() => {
|
|
268
278
|
if (!collab || !awareness || !ydoc) {
|
|
269
279
|
setIsLeadClient(true);
|
|
@@ -299,25 +309,73 @@ export function useCollabReconcile({
|
|
|
299
309
|
if (!collab || !editor || editor.isDestroyed || !ydoc) return;
|
|
300
310
|
if (seededRef.current) return;
|
|
301
311
|
if (!collabSynced) return;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
// app's sentinel-empty filler via a custom `shouldSeed`).
|
|
309
|
-
if (
|
|
310
|
-
!shouldSeed({ value, currentMarkdown, fragmentLength: fragment.length })
|
|
311
|
-
) {
|
|
312
|
+
// An empty SQL value has nothing to seed. Release the first real keystroke
|
|
313
|
+
// immediately, but when a fragment already exists defer the ambiguous
|
|
314
|
+
// reconcile decision for one task: active-peer or just-emitted local content
|
|
315
|
+
// is live and must be adopted; stale persisted CRDT with no active writer is
|
|
316
|
+
// cleared by the canonical SQL snapshot.
|
|
317
|
+
if (!value.trim()) {
|
|
312
318
|
seededRef.current = true;
|
|
313
|
-
|
|
314
|
-
|
|
319
|
+
const fragment = ydoc.getXmlFragment("default");
|
|
320
|
+
const currentMarkdown = getMarkdown(editor);
|
|
321
|
+
if (fragment.length === 0 && !currentMarkdown.trim()) return;
|
|
315
322
|
|
|
323
|
+
emptySnapshotDecisionPendingRef.current = true;
|
|
324
|
+
let cancelled = false;
|
|
325
|
+
const adoptTimer = setTimeout(
|
|
326
|
+
() => {
|
|
327
|
+
if (cancelled || editor.isDestroyed) return;
|
|
328
|
+
const projectedMarkdown = getMarkdown(editor);
|
|
329
|
+
const isOwnFreshEdit =
|
|
330
|
+
projectedMarkdown.trim() &&
|
|
331
|
+
(projectedMarkdown === lastEmittedRef.current ||
|
|
332
|
+
recentEmittedRef.current.includes(projectedMarkdown));
|
|
333
|
+
if (
|
|
334
|
+
projectedMarkdown.trim() &&
|
|
335
|
+
(peerCountRef.current > 0 || isOwnFreshEdit)
|
|
336
|
+
) {
|
|
337
|
+
lastAppliedValueRef.current = value;
|
|
338
|
+
lastAppliedSerializedRef.current = projectedMarkdown;
|
|
339
|
+
if (contentUpdatedAt) {
|
|
340
|
+
lastAppliedUpdatedAtRef.current = contentUpdatedAt;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
emptySnapshotDecisionPendingRef.current = false;
|
|
344
|
+
},
|
|
345
|
+
awareness ? PEER_SETTLE_MS : 0,
|
|
346
|
+
);
|
|
347
|
+
return () => {
|
|
348
|
+
cancelled = true;
|
|
349
|
+
clearTimeout(adoptTimer);
|
|
350
|
+
emptySnapshotDecisionPendingRef.current = false;
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (!isLeadClient) return;
|
|
316
354
|
let cancelled = false;
|
|
317
355
|
// Defer via a timer task (NOT a microtask — microtasks can still run
|
|
318
356
|
// inside React's commit and trigger flushSync-from-lifecycle warnings).
|
|
357
|
+
// Read the editor and fragment INSIDE that task. The collaboration
|
|
358
|
+
// extension can finish projecting an already-populated Y.Doc into
|
|
359
|
+
// ProseMirror after this effect is scheduled. Capturing the pre-projection
|
|
360
|
+
// empty editor here would incorrectly seed the SQL snapshot alongside the
|
|
361
|
+
// existing CRDT content, duplicating the whole document after reload.
|
|
319
362
|
const seedTimer = setTimeout(() => {
|
|
320
363
|
if (cancelled || editor.isDestroyed) return;
|
|
364
|
+
const fragment = ydoc.getXmlFragment("default");
|
|
365
|
+
const currentMarkdown = getMarkdown(editor);
|
|
366
|
+
// Seed only when the shared doc is genuinely empty — either the fragment
|
|
367
|
+
// has no nodes yet, or it holds no semantic markdown (an empty paragraph,
|
|
368
|
+
// or an app's sentinel-empty filler via a custom `shouldSeed`).
|
|
369
|
+
if (
|
|
370
|
+
!shouldSeed({
|
|
371
|
+
value,
|
|
372
|
+
currentMarkdown,
|
|
373
|
+
fragmentLength: fragment.length,
|
|
374
|
+
})
|
|
375
|
+
) {
|
|
376
|
+
seededRef.current = true;
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
321
379
|
isSettingContentRef.current = true;
|
|
322
380
|
try {
|
|
323
381
|
setContent(editor, value, {});
|
|
@@ -362,16 +420,26 @@ export function useCollabReconcile({
|
|
|
362
420
|
// With peers present, a peer's edit also arrives via Yjs. Defer one poll
|
|
363
421
|
// cycle (+margin) and re-check before applying via setContent so the same
|
|
364
422
|
// change isn't inserted twice (Yjs + setContent → duplicated region).
|
|
365
|
-
const PEER_SETTLE_MS = 2500;
|
|
366
|
-
|
|
367
423
|
const apply = (deferred = false) => {
|
|
368
424
|
if (cancelled || editor.isDestroyed) return;
|
|
369
425
|
// In collab mode, defer all reconcile until the shared doc is seeded so we
|
|
370
426
|
// never setContent over an unseeded fragment.
|
|
371
|
-
if (collab &&
|
|
427
|
+
if (collab && !collabSynced) {
|
|
372
428
|
retry = setTimeout(() => apply(deferred), 300);
|
|
373
429
|
return;
|
|
374
430
|
}
|
|
431
|
+
// The seed decision itself is deferred one task so Collaboration can
|
|
432
|
+
// project persisted Y.Doc state before we inspect it. Poll that short
|
|
433
|
+
// handoff promptly: waiting the full provider cadence here makes a newer
|
|
434
|
+
// canonical SQL snapshot visibly stale after reload.
|
|
435
|
+
if (collab && !seededRef.current) {
|
|
436
|
+
retry = setTimeout(() => apply(deferred), 25);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (collab && emptySnapshotDecisionPendingRef.current) {
|
|
440
|
+
retry = setTimeout(() => apply(deferred), 50);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
375
443
|
const currentMarkdown = getMarkdown(editor);
|
|
376
444
|
// Compare against the canonical form the editor would emit so a serializer
|
|
377
445
|
// that re-normalizes (Content's NFM) still recognizes "already in sync".
|
package/src/ui/button.tsx
CHANGED
|
@@ -22,8 +22,7 @@ const buttonVariants = cva(
|
|
|
22
22
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
23
23
|
destructive:
|
|
24
24
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
25
|
-
outline:
|
|
26
|
-
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
25
|
+
outline: "bg-accent text-accent-foreground hover:bg-accent/80",
|
|
27
26
|
secondary:
|
|
28
27
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
29
28
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
package/src/ui/card.tsx
CHANGED
package/src/ui/table.tsx
CHANGED
|
@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
|
|
|
20
20
|
HTMLTableSectionElement,
|
|
21
21
|
React.HTMLAttributes<HTMLTableSectionElement>
|
|
22
22
|
>(({ className, ...props }, ref) => (
|
|
23
|
-
<thead ref={ref} className={
|
|
23
|
+
<thead ref={ref} className={className} {...props} />
|
|
24
24
|
));
|
|
25
25
|
TableHeader.displayName = "TableHeader";
|
|
26
26
|
|
|
@@ -28,11 +28,7 @@ const TableBody = React.forwardRef<
|
|
|
28
28
|
HTMLTableSectionElement,
|
|
29
29
|
React.HTMLAttributes<HTMLTableSectionElement>
|
|
30
30
|
>(({ className, ...props }, ref) => (
|
|
31
|
-
<tbody
|
|
32
|
-
ref={ref}
|
|
33
|
-
className={cn("[&_tr:last-child]:border-0", className)}
|
|
34
|
-
{...props}
|
|
35
|
-
/>
|
|
31
|
+
<tbody ref={ref} className={className} {...props} />
|
|
36
32
|
));
|
|
37
33
|
TableBody.displayName = "TableBody";
|
|
38
34
|
|
|
@@ -42,10 +38,7 @@ const TableFooter = React.forwardRef<
|
|
|
42
38
|
>(({ className, ...props }, ref) => (
|
|
43
39
|
<tfoot
|
|
44
40
|
ref={ref}
|
|
45
|
-
className={cn(
|
|
46
|
-
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
|
47
|
-
className,
|
|
48
|
-
)}
|
|
41
|
+
className={cn("bg-muted/50 font-medium", className)}
|
|
49
42
|
{...props}
|
|
50
43
|
/>
|
|
51
44
|
));
|
|
@@ -58,7 +51,7 @@ const TableRow = React.forwardRef<
|
|
|
58
51
|
<tr
|
|
59
52
|
ref={ref}
|
|
60
53
|
className={cn(
|
|
61
|
-
"
|
|
54
|
+
"transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
|
62
55
|
className,
|
|
63
56
|
)}
|
|
64
57
|
{...props}
|