@agent-native/toolkit 0.20.4-nightly-20260917183518 → 0.20.4

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 (31) hide show
  1. package/dist/design-system/default-adapter.d.ts.map +1 -1
  2. package/dist/design-system/default-adapter.js +4 -2
  3. package/dist/design-system/default-adapter.js.map +1 -1
  4. package/dist/design-system/default-adapter.spec.js +8 -0
  5. package/dist/design-system/default-adapter.spec.js.map +1 -1
  6. package/dist/design-system/design-system.spec.js +31 -2
  7. package/dist/design-system/design-system.spec.js.map +1 -1
  8. package/dist/design-system/types.d.ts +2 -0
  9. package/dist/design-system/types.d.ts.map +1 -1
  10. package/dist/editor/useCollabReconcile.concurrent.spec.js +282 -1
  11. package/dist/editor/useCollabReconcile.concurrent.spec.js.map +1 -1
  12. package/dist/editor/useCollabReconcile.d.ts +7 -1
  13. package/dist/editor/useCollabReconcile.d.ts.map +1 -1
  14. package/dist/editor/useCollabReconcile.js +89 -8
  15. package/dist/editor/useCollabReconcile.js.map +1 -1
  16. package/dist/provider.d.ts.map +1 -1
  17. package/dist/provider.js +12 -10
  18. package/dist/provider.js.map +1 -1
  19. package/dist/ui/button.d.ts +3 -2
  20. package/dist/ui/button.d.ts.map +1 -1
  21. package/dist/ui/button.js +15 -7
  22. package/dist/ui/button.js.map +1 -1
  23. package/package.json +1 -1
  24. package/src/design-system/default-adapter.spec.tsx +10 -0
  25. package/src/design-system/default-adapter.tsx +12 -4
  26. package/src/design-system/design-system.spec.tsx +65 -2
  27. package/src/design-system/types.ts +2 -0
  28. package/src/editor/useCollabReconcile.concurrent.spec.ts +357 -0
  29. package/src/editor/useCollabReconcile.ts +120 -9
  30. package/src/provider.tsx +12 -9
  31. package/src/ui/button.tsx +21 -8
@@ -49,6 +49,7 @@ interface HarnessProps {
49
49
  value: string;
50
50
  contentUpdatedAt: string;
51
51
  contentRevision?: string;
52
+ compareContentRevisions?: (first: string, second: string) => number | null;
52
53
  acknowledgedLocalSnapshot?: {
53
54
  value: string;
54
55
  revision: string;
@@ -86,6 +87,7 @@ function makeHarness() {
86
87
  value,
87
88
  contentUpdatedAt,
88
89
  contentRevision,
90
+ compareContentRevisions,
89
91
  acknowledgedLocalSnapshot,
90
92
  editorOwnedFocus = false,
91
93
  isEditorFocused,
@@ -112,6 +114,7 @@ function makeHarness() {
112
114
  value,
113
115
  contentUpdatedAt,
114
116
  contentRevision,
117
+ compareContentRevisions,
115
118
  acknowledgedLocalSnapshot,
116
119
  onBaseAwareReconcile: (result) => {
117
120
  (captured.reconciled ??= []).push({
@@ -629,6 +632,50 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
629
632
  },
630
633
  );
631
634
 
635
+ it("does not replay sequential SQL acknowledgements over two-tab Yjs edits", async () => {
636
+ const harness = makeConnectedEditorHarness(false);
637
+ const baseline = "Alpha\n\nBravo";
638
+ const firstSave = "Alpha\n\nBravo from first tab";
639
+ const merged = "Second tab Alpha\n\nBravo from first tab";
640
+ vi.useFakeTimers();
641
+ try {
642
+ render(root, harness.Harness, {
643
+ value: baseline,
644
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
645
+ contentRevision: "revision-1",
646
+ });
647
+ await act(async () => vi.advanceTimersByTimeAsync(30));
648
+
649
+ act(() => harness.editors[0]!.commands.setContent(firstSave));
650
+ act(() => harness.editors[1]!.commands.setContent(merged));
651
+ expect(harness.markdown()).toEqual([merged, merged]);
652
+
653
+ render(root, harness.Harness, {
654
+ value: firstSave,
655
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
656
+ contentRevision: "revision-2",
657
+ });
658
+ await act(async () => vi.advanceTimersByTimeAsync(2501));
659
+ render(root, harness.Harness, {
660
+ value: merged,
661
+ contentUpdatedAt: "2024-01-01T00:00:03.000Z",
662
+ contentRevision: "revision-3",
663
+ });
664
+ await act(async () => vi.advanceTimersByTimeAsync(2501));
665
+
666
+ expect(harness.markdown()).toEqual([merged, merged]);
667
+ expect(harness.markdown()[0]!.match(/Second tab Alpha/g)).toHaveLength(1);
668
+ expect(
669
+ harness.markdown()[0]!.match(/Bravo from first tab/g),
670
+ ).toHaveLength(1);
671
+ expect(harness.reconciled).toEqual([
672
+ { status: "merged", content: merged },
673
+ ]);
674
+ } finally {
675
+ harness.dispose();
676
+ }
677
+ });
678
+
632
679
  it("uses the latest callback at the original peer deadline, including the default normalizer", async () => {
633
680
  const harness = makePeerReconcileHarness();
634
681
  vi.useFakeTimers();
@@ -1224,6 +1271,316 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
1224
1271
  ]);
1225
1272
  });
1226
1273
 
1274
+ it("does not replace an external base with an acknowledgement at the same timestamp", async () => {
1275
+ vi.useFakeTimers();
1276
+ const { captured, Harness } = makeHarness();
1277
+ const compareContentRevisions = (first: string, second: string) =>
1278
+ Number(first.split("-")[1]) - Number(second.split("-")[1]);
1279
+ render(root, Harness, {
1280
+ value: "Alpha saved\n\nBravo\n\nCharlie",
1281
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
1282
+ contentRevision: "revision-1",
1283
+ compareContentRevisions,
1284
+ });
1285
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1286
+
1287
+ act(() =>
1288
+ captured.editor!.commands.setContent(
1289
+ "Alpha saved\n\nBravo\n\nCharlie local",
1290
+ ),
1291
+ );
1292
+ render(root, Harness, {
1293
+ value: "Alpha saved\n\nBravo server\n\nCharlie",
1294
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
1295
+ contentRevision: "revision-3",
1296
+ compareContentRevisions,
1297
+ });
1298
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1299
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1300
+ "Alpha saved\n\nBravo server\n\nCharlie local",
1301
+ );
1302
+
1303
+ const staleAcknowledgement = {
1304
+ value: "Alpha saved\n\nBravo\n\nCharlie",
1305
+ revision: "revision-2",
1306
+ updatedAt: "2024-01-01T00:00:02.000Z",
1307
+ sequence: 1,
1308
+ };
1309
+ render(root, Harness, {
1310
+ value: staleAcknowledgement.value,
1311
+ contentUpdatedAt: staleAcknowledgement.updatedAt,
1312
+ contentRevision: staleAcknowledgement.revision,
1313
+ compareContentRevisions,
1314
+ acknowledgedLocalSnapshot: staleAcknowledgement,
1315
+ });
1316
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1317
+
1318
+ render(root, Harness, {
1319
+ value: "Alpha saved\n\nBravo remote\n\nCharlie",
1320
+ contentUpdatedAt: "2024-01-01T00:00:03.000Z",
1321
+ contentRevision: "revision-4",
1322
+ compareContentRevisions,
1323
+ acknowledgedLocalSnapshot: staleAcknowledgement,
1324
+ });
1325
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1326
+
1327
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1328
+ "Alpha saved\n\nBravo remote\n\nCharlie local",
1329
+ );
1330
+ expect(captured.reconciled).toEqual([
1331
+ {
1332
+ status: "merged",
1333
+ content: "Alpha saved\n\nBravo server\n\nCharlie local",
1334
+ },
1335
+ {
1336
+ status: "merged",
1337
+ content: "Alpha saved\n\nBravo remote\n\nCharlie local",
1338
+ },
1339
+ ]);
1340
+ });
1341
+
1342
+ it("restores the prior base when an external revision follows an equal-timestamp acknowledgement", async () => {
1343
+ vi.useFakeTimers();
1344
+ const { captured, Harness } = makeHarness();
1345
+ render(root, Harness, {
1346
+ value: "Alpha\n\nBravo\n\nCharlie",
1347
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
1348
+ contentRevision: "revision-1",
1349
+ });
1350
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1351
+
1352
+ act(() =>
1353
+ captured.editor!.commands.setContent(
1354
+ "Alpha local\n\nBravo\n\nCharlie local",
1355
+ ),
1356
+ );
1357
+ const acknowledgement = {
1358
+ value: "Alpha local\n\nBravo\n\nCharlie",
1359
+ revision: "revision-2",
1360
+ updatedAt: "2024-01-01T00:00:02.000Z",
1361
+ sequence: 1,
1362
+ };
1363
+ render(root, Harness, {
1364
+ value: acknowledgement.value,
1365
+ contentUpdatedAt: acknowledgement.updatedAt,
1366
+ contentRevision: acknowledgement.revision,
1367
+ acknowledgedLocalSnapshot: acknowledgement,
1368
+ });
1369
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1370
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1371
+ "Alpha local\n\nBravo\n\nCharlie local",
1372
+ );
1373
+
1374
+ render(root, Harness, {
1375
+ value: "Alpha external\n\nBravo server\n\nCharlie",
1376
+ contentUpdatedAt: acknowledgement.updatedAt,
1377
+ contentRevision: "revision-3",
1378
+ acknowledgedLocalSnapshot: acknowledgement,
1379
+ });
1380
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1381
+
1382
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1383
+ "Alpha local\n\nBravo\n\nCharlie local",
1384
+ );
1385
+ expect(captured.reconciled).toEqual([
1386
+ {
1387
+ status: "conflict",
1388
+ content: "Alpha local\n\nBravo\n\nCharlie local",
1389
+ },
1390
+ ]);
1391
+ });
1392
+
1393
+ it("preserves the prior base when an acknowledgement precedes its canonical snapshot", async () => {
1394
+ vi.useFakeTimers();
1395
+ const { captured, Harness } = makeHarness();
1396
+ const initial = {
1397
+ value: "Alpha\n\nBravo\n\nCharlie",
1398
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
1399
+ contentRevision: "revision-1",
1400
+ };
1401
+ render(root, Harness, initial);
1402
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1403
+
1404
+ act(() =>
1405
+ captured.editor!.commands.setContent(
1406
+ "Alpha local\n\nBravo\n\nCharlie local",
1407
+ ),
1408
+ );
1409
+ const acknowledgement = {
1410
+ value: "Alpha local\n\nBravo\n\nCharlie",
1411
+ revision: "revision-2",
1412
+ updatedAt: "2024-01-01T00:00:02.000Z",
1413
+ sequence: 1,
1414
+ };
1415
+ render(root, Harness, {
1416
+ ...initial,
1417
+ acknowledgedLocalSnapshot: acknowledgement,
1418
+ });
1419
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1420
+
1421
+ render(root, Harness, {
1422
+ value: "Alpha external\n\nBravo server\n\nCharlie",
1423
+ contentUpdatedAt: acknowledgement.updatedAt,
1424
+ contentRevision: "revision-3",
1425
+ acknowledgedLocalSnapshot: acknowledgement,
1426
+ });
1427
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1428
+
1429
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1430
+ "Alpha local\n\nBravo\n\nCharlie local",
1431
+ );
1432
+ expect(captured.reconciled).toEqual([
1433
+ {
1434
+ status: "conflict",
1435
+ content: "Alpha local\n\nBravo\n\nCharlie local",
1436
+ },
1437
+ ]);
1438
+ });
1439
+
1440
+ it("ignores a delayed different revision at an accepted timestamp", async () => {
1441
+ vi.useFakeTimers();
1442
+ const { captured, Harness } = makeHarness();
1443
+ render(root, Harness, {
1444
+ value: "Alpha\n\nBravo\n\nCharlie",
1445
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
1446
+ contentRevision: "revision-1",
1447
+ });
1448
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1449
+
1450
+ act(() =>
1451
+ captured.editor!.commands.setContent("Alpha\n\nBravo\n\nCharlie local"),
1452
+ );
1453
+ render(root, Harness, {
1454
+ value: "Alpha\n\nBravo server\n\nCharlie",
1455
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
1456
+ contentRevision: "body:3:sha256:newest",
1457
+ compareContentRevisions: (first, second) =>
1458
+ Number(first.split(":")[1]) - Number(second.split(":")[1]),
1459
+ });
1460
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1461
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1462
+ "Alpha\n\nBravo server\n\nCharlie local",
1463
+ );
1464
+
1465
+ render(root, Harness, {
1466
+ value: "Alpha stale\n\nBravo\n\nCharlie",
1467
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
1468
+ contentRevision: "body:2:sha256:delayed",
1469
+ compareContentRevisions: (first, second) =>
1470
+ Number(first.split(":")[1]) - Number(second.split(":")[1]),
1471
+ });
1472
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1473
+
1474
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1475
+ "Alpha\n\nBravo server\n\nCharlie local",
1476
+ );
1477
+ expect(captured.reconciled).toEqual([
1478
+ {
1479
+ status: "merged",
1480
+ content: "Alpha\n\nBravo server\n\nCharlie local",
1481
+ },
1482
+ ]);
1483
+ });
1484
+
1485
+ it("accepts a newer acknowledgement at an observed timestamp as the merge base", async () => {
1486
+ vi.useFakeTimers();
1487
+ const { captured, Harness } = makeHarness();
1488
+ const compareContentRevisions = (first: string, second: string) =>
1489
+ Number(first.split(":")[1]) - Number(second.split(":")[1]);
1490
+ const timestamp = "2024-01-01T00:00:02.000Z";
1491
+ render(root, Harness, {
1492
+ value: "Alpha\n\nBravo\n\nCharlie",
1493
+ contentUpdatedAt: timestamp,
1494
+ contentRevision: "body:1:sha256:initial",
1495
+ compareContentRevisions,
1496
+ });
1497
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1498
+
1499
+ act(() =>
1500
+ captured.editor!.commands.setContent(
1501
+ "Alpha local\n\nBravo\n\nCharlie local",
1502
+ ),
1503
+ );
1504
+ const acknowledgement = {
1505
+ value: "Alpha local\n\nBravo\n\nCharlie",
1506
+ revision: "body:2:sha256:acknowledged",
1507
+ updatedAt: timestamp,
1508
+ sequence: 1,
1509
+ };
1510
+ render(root, Harness, {
1511
+ value: "Alpha\n\nBravo\n\nCharlie",
1512
+ contentUpdatedAt: timestamp,
1513
+ contentRevision: "body:1:sha256:initial",
1514
+ compareContentRevisions,
1515
+ acknowledgedLocalSnapshot: acknowledgement,
1516
+ });
1517
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1518
+
1519
+ render(root, Harness, {
1520
+ value: "Alpha external\n\nBravo server\n\nCharlie",
1521
+ contentUpdatedAt: timestamp,
1522
+ contentRevision: "body:3:sha256:external",
1523
+ compareContentRevisions,
1524
+ acknowledgedLocalSnapshot: acknowledgement,
1525
+ });
1526
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1527
+
1528
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1529
+ "Alpha external\n\nBravo server\n\nCharlie local",
1530
+ );
1531
+ expect(captured.reconciled?.at(-1)).toEqual({
1532
+ status: "merged",
1533
+ content: "Alpha external\n\nBravo server\n\nCharlie local",
1534
+ });
1535
+ });
1536
+
1537
+ it("accepts an opaque acknowledgement at an observed timestamp as the merge base", async () => {
1538
+ vi.useFakeTimers();
1539
+ const { captured, Harness } = makeHarness();
1540
+ const timestamp = "2024-01-01T00:00:02.000Z";
1541
+ render(root, Harness, {
1542
+ value: "Alpha\n\nBravo\n\nCharlie",
1543
+ contentUpdatedAt: timestamp,
1544
+ contentRevision: "initial",
1545
+ });
1546
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1547
+
1548
+ act(() =>
1549
+ captured.editor!.commands.setContent(
1550
+ "Alpha local\n\nBravo\n\nCharlie local",
1551
+ ),
1552
+ );
1553
+ const acknowledgement = {
1554
+ value: "Alpha local\n\nBravo\n\nCharlie",
1555
+ revision: "acknowledged",
1556
+ updatedAt: timestamp,
1557
+ sequence: 1,
1558
+ };
1559
+ render(root, Harness, {
1560
+ value: "Alpha\n\nBravo\n\nCharlie",
1561
+ contentUpdatedAt: timestamp,
1562
+ contentRevision: "initial",
1563
+ acknowledgedLocalSnapshot: acknowledgement,
1564
+ });
1565
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1566
+
1567
+ render(root, Harness, {
1568
+ value: "Alpha external\n\nBravo server\n\nCharlie",
1569
+ contentUpdatedAt: timestamp,
1570
+ contentRevision: "external",
1571
+ acknowledgedLocalSnapshot: acknowledgement,
1572
+ });
1573
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1574
+
1575
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1576
+ "Alpha external\n\nBravo server\n\nCharlie local",
1577
+ );
1578
+ expect(captured.reconciled?.at(-1)).toEqual({
1579
+ status: "merged",
1580
+ content: "Alpha external\n\nBravo server\n\nCharlie local",
1581
+ });
1582
+ });
1583
+
1227
1584
  it("persists the first local edit after a synced empty collaborative document", async () => {
1228
1585
  const captured: Captured = {
1229
1586
  editor: null,
@@ -63,6 +63,12 @@ export interface UseCollabReconcileOptions {
63
63
  contentUpdatedAt?: string | null;
64
64
  /** Opaque authoritative body revision. Enables base-aware reconciliation. */
65
65
  contentRevision?: string | null;
66
+ /**
67
+ * Orders two revision identities when their timestamps tie. Return a positive
68
+ * number when the first revision is newer, zero when equivalent, a negative
69
+ * number when older, or null when this revision format cannot be ordered.
70
+ */
71
+ compareContentRevisions?: (first: string, second: string) => number | null;
66
72
  /**
67
73
  * A server-confirmed snapshot written by this editor. This is deliberately
68
74
  * separate from `registerEmitted`: an emitted value may still fail to save,
@@ -263,6 +269,7 @@ export function useCollabReconcile({
263
269
  value,
264
270
  contentUpdatedAt,
265
271
  contentRevision,
272
+ compareContentRevisions,
266
273
  acknowledgedLocalSnapshot,
267
274
  collabContentRevision,
268
275
  requestCollabSync,
@@ -323,6 +330,14 @@ export function useCollabReconcile({
323
330
  const latestObservedUpdatedAtRef = useRef<string | null>(
324
331
  contentUpdatedAt ?? null,
325
332
  );
333
+ const latestObservedRevisionRef = useRef<string | null>(
334
+ contentRevision ?? null,
335
+ );
336
+ const acknowledgementBaseRollbackRef = useRef<{
337
+ acknowledgementRevision: string;
338
+ updatedAt: string;
339
+ base: { value: string; revision: string } | null;
340
+ } | null>(null);
326
341
  const acknowledgedCollabRef = useRef<{ ydoc: YDoc; revision: string } | null>(
327
342
  null,
328
343
  );
@@ -616,28 +631,115 @@ export function useCollabReconcile({
616
631
  // change isn't inserted twice (Yjs + setContent → duplicated region).
617
632
  const apply = (deferred = false) => {
618
633
  if (cancelled || editor.isDestroyed) return;
619
- if (
620
- contentUpdatedAt &&
621
- (!latestObservedUpdatedAtRef.current ||
622
- contentUpdatedAt > latestObservedUpdatedAtRef.current)
623
- ) {
624
- latestObservedUpdatedAtRef.current = contentUpdatedAt;
634
+ if (contentUpdatedAt) {
635
+ const rollback = acknowledgementBaseRollbackRef.current;
636
+ const conflictsWithAcceptedAcknowledgement =
637
+ contentRevision &&
638
+ rollback?.updatedAt === contentUpdatedAt &&
639
+ rollback.acknowledgementRevision !== contentRevision;
640
+ if (conflictsWithAcceptedAcknowledgement) {
641
+ const order = compareContentRevisions?.(
642
+ contentRevision,
643
+ rollback.acknowledgementRevision,
644
+ );
645
+ if (order !== undefined && order !== null && order <= 0) {
646
+ return;
647
+ }
648
+ // The acknowledgement advanced our base before its SQL snapshot was
649
+ // observed, but another revision won at the same timestamp. Restore
650
+ // the common base and treat this first canonical winner as the
651
+ // authoritative revision for that otherwise unordered timestamp.
652
+ if (
653
+ authoritativeBaseRef.current?.revision ===
654
+ rollback.acknowledgementRevision
655
+ ) {
656
+ authoritativeBaseRef.current = rollback.base;
657
+ }
658
+ acknowledgementBaseRollbackRef.current = null;
659
+ latestObservedUpdatedAtRef.current = contentUpdatedAt;
660
+ latestObservedRevisionRef.current = contentRevision;
661
+ } else if (
662
+ !latestObservedUpdatedAtRef.current ||
663
+ contentUpdatedAt > latestObservedUpdatedAtRef.current
664
+ ) {
665
+ latestObservedUpdatedAtRef.current = contentUpdatedAt;
666
+ latestObservedRevisionRef.current = contentRevision ?? null;
667
+ acknowledgementBaseRollbackRef.current = null;
668
+ } else if (
669
+ contentUpdatedAt === latestObservedUpdatedAtRef.current &&
670
+ contentRevision &&
671
+ latestObservedRevisionRef.current !== contentRevision
672
+ ) {
673
+ const order = latestObservedRevisionRef.current
674
+ ? compareContentRevisions?.(
675
+ contentRevision,
676
+ latestObservedRevisionRef.current,
677
+ )
678
+ : null;
679
+ if (order !== undefined && order !== null && order <= 0) {
680
+ return;
681
+ }
682
+ // Legacy opaque revisions cannot be ordered. Preserve their existing
683
+ // reconcile behavior; ordered body revisions retain the newest
684
+ // identity so a delayed snapshot cannot roll it back.
685
+ latestObservedRevisionRef.current =
686
+ order !== undefined && order !== null ? contentRevision : null;
687
+ }
625
688
  }
689
+ let rejectedMatchingAcknowledgement = false;
626
690
  if (acknowledgedLocalSnapshot) {
627
691
  const acceptedAcknowledgement = acknowledgedLocalSnapshotRef.current;
628
692
  const acknowledgementIsNewestAccepted =
629
693
  !acceptedAcknowledgement ||
630
694
  acknowledgedLocalSnapshot.sequence > acceptedAcknowledgement.sequence;
695
+ const acknowledgementRevisionOrder =
696
+ acknowledgedLocalSnapshot.updatedAt ===
697
+ latestObservedUpdatedAtRef.current &&
698
+ latestObservedRevisionRef.current
699
+ ? compareContentRevisions?.(
700
+ acknowledgedLocalSnapshot.revision,
701
+ latestObservedRevisionRef.current,
702
+ )
703
+ : null;
631
704
  const acknowledgementIsNotSuperseded =
632
705
  !latestObservedUpdatedAtRef.current ||
633
- acknowledgedLocalSnapshot.updatedAt >=
634
- latestObservedUpdatedAtRef.current;
706
+ acknowledgedLocalSnapshot.updatedAt >
707
+ latestObservedUpdatedAtRef.current ||
708
+ (acknowledgedLocalSnapshot.updatedAt ===
709
+ latestObservedUpdatedAtRef.current &&
710
+ (latestObservedRevisionRef.current ===
711
+ acknowledgedLocalSnapshot.revision ||
712
+ acknowledgementRevisionOrder === undefined ||
713
+ acknowledgementRevisionOrder === null ||
714
+ acknowledgementRevisionOrder >= 0));
635
715
  if (acknowledgementIsNewestAccepted && acknowledgementIsNotSuperseded) {
636
716
  acknowledgedLocalSnapshotRef.current = acknowledgedLocalSnapshot;
717
+ const existingRollback = acknowledgementBaseRollbackRef.current;
718
+ if (
719
+ existingRollback?.acknowledgementRevision !==
720
+ acknowledgedLocalSnapshot.revision ||
721
+ existingRollback.updatedAt !== acknowledgedLocalSnapshot.updatedAt
722
+ ) {
723
+ acknowledgementBaseRollbackRef.current = {
724
+ acknowledgementRevision: acknowledgedLocalSnapshot.revision,
725
+ updatedAt: acknowledgedLocalSnapshot.updatedAt,
726
+ base: authoritativeBaseRef.current,
727
+ };
728
+ }
637
729
  authoritativeBaseRef.current = {
638
730
  value: acknowledgedLocalSnapshot.value,
639
731
  revision: acknowledgedLocalSnapshot.revision,
640
732
  };
733
+ if (
734
+ acknowledgedLocalSnapshot.updatedAt ===
735
+ latestObservedUpdatedAtRef.current &&
736
+ acknowledgementRevisionOrder !== undefined &&
737
+ acknowledgementRevisionOrder !== null &&
738
+ acknowledgementRevisionOrder > 0
739
+ ) {
740
+ latestObservedRevisionRef.current =
741
+ acknowledgedLocalSnapshot.revision;
742
+ }
641
743
  if (
642
744
  !lastAppliedUpdatedAtRef.current ||
643
745
  acknowledgedLocalSnapshot.updatedAt >
@@ -646,8 +748,14 @@ export function useCollabReconcile({
646
748
  lastAppliedUpdatedAtRef.current =
647
749
  acknowledgedLocalSnapshot.updatedAt;
648
750
  }
751
+ } else if (
752
+ contentRevision === acknowledgedLocalSnapshot.revision &&
753
+ value === acknowledgedLocalSnapshot.value
754
+ ) {
755
+ rejectedMatchingAcknowledgement = true;
649
756
  }
650
757
  }
758
+ if (rejectedMatchingAcknowledgement) return;
651
759
  const acknowledged = acknowledgedLocalSnapshotRef.current;
652
760
  if (
653
761
  acknowledged &&
@@ -656,7 +764,9 @@ export function useCollabReconcile({
656
764
  ) {
657
765
  const acknowledgementWasSuperseded =
658
766
  latestObservedUpdatedAtRef.current !== null &&
659
- acknowledged.updatedAt < latestObservedUpdatedAtRef.current;
767
+ (acknowledged.updatedAt < latestObservedUpdatedAtRef.current ||
768
+ (acknowledged.updatedAt === latestObservedUpdatedAtRef.current &&
769
+ latestObservedRevisionRef.current !== acknowledged.revision));
660
770
  if (!acknowledgementWasSuperseded) {
661
771
  authoritativeBaseRef.current = {
662
772
  value: acknowledged.value,
@@ -990,6 +1100,7 @@ export function useCollabReconcile({
990
1100
  }, [
991
1101
  contentUpdatedAt,
992
1102
  contentRevision,
1103
+ compareContentRevisions,
993
1104
  acknowledgedLocalSnapshot,
994
1105
  editor,
995
1106
  ydoc,
package/src/provider.tsx CHANGED
@@ -65,6 +65,7 @@ export function ToolkitProvider({
65
65
  children,
66
66
  intent,
67
67
  emphasis,
68
+ inset,
68
69
  size,
69
70
  pending,
70
71
  leadingIcon,
@@ -75,15 +76,17 @@ export function ToolkitProvider({
75
76
  ...props
76
77
  }: ActionButtonProps) {
77
78
  const variant =
78
- emphasis === "ghost"
79
- ? "ghost"
80
- : emphasis === "outline"
81
- ? "outline"
82
- : intent === "danger"
83
- ? "destructive"
84
- : intent === "primary"
85
- ? "default"
86
- : "secondary";
79
+ inset && emphasis === "ghost"
80
+ ? "ghost-inset"
81
+ : emphasis === "ghost"
82
+ ? "ghost"
83
+ : emphasis === "outline"
84
+ ? "outline"
85
+ : intent === "danger"
86
+ ? "destructive"
87
+ : intent === "primary"
88
+ ? "default"
89
+ : "secondary";
87
90
  const legacySize =
88
91
  size === "compact" ? "sm" : size === "large" ? "lg" : "default";
89
92
  return (
package/src/ui/button.tsx CHANGED
@@ -14,6 +14,8 @@ import type {
14
14
  import { useToolkitComponent } from "../provider.js";
15
15
  import { cn } from "../utils.js";
16
16
 
17
+ export type ButtonEmphasis = DesignSystemEmphasis | "ghost-inset";
18
+
17
19
  const buttonVariants = cva(
18
20
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-[color,background-color,border-color,box-shadow,transform,scale] duration-150 active:scale-[0.98] motion-reduce:active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
19
21
  {
@@ -26,6 +28,8 @@ const buttonVariants = cva(
26
28
  secondary:
27
29
  "bg-secondary text-secondary-foreground hover:bg-secondary/80",
28
30
  ghost: "hover:bg-accent hover:text-accent-foreground",
31
+ "ghost-inset":
32
+ "text-muted-foreground hover:bg-accent/40 hover:text-foreground focus-visible:ring-inset focus-visible:ring-offset-0",
29
33
  link: "text-primary underline-offset-4 hover:underline",
30
34
  },
31
35
  size: {
@@ -50,7 +54,7 @@ export interface ButtonProps
50
54
  /** Semantic meaning forwarded to a registered design-system ActionButton. */
51
55
  intent?: DesignSystemIntent;
52
56
  /** Semantic prominence forwarded independently of the default visual variant. */
53
- emphasis?: DesignSystemEmphasis;
57
+ emphasis?: ButtonEmphasis;
54
58
  }
55
59
 
56
60
  const ButtonBase = React.forwardRef<HTMLButtonElement, ButtonProps>(
@@ -104,13 +108,21 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
104
108
  : variant === "default"
105
109
  ? "primary"
106
110
  : "neutral");
107
- const semanticEmphasis =
108
- emphasis ??
109
- (variant === "outline"
110
- ? "outline"
111
- : variant === "ghost" || variant === "link"
112
- ? "ghost"
113
- : "solid");
111
+ const semanticEmphasis: DesignSystemEmphasis =
112
+ emphasis === "ghost-inset"
113
+ ? "ghost"
114
+ : (emphasis ??
115
+ (variant === "outline"
116
+ ? "outline"
117
+ : variant === "ghost-inset"
118
+ ? "ghost"
119
+ : variant === "ghost" || variant === "link"
120
+ ? "ghost"
121
+ : "solid"));
122
+ const inset =
123
+ semanticEmphasis === "ghost" &&
124
+ (emphasis === "ghost-inset" ||
125
+ (!emphasis && variant === "ghost-inset"));
114
126
  const semanticSize =
115
127
  size === "sm" ? "compact" : size === "lg" ? "large" : "default";
116
128
  return (
@@ -128,6 +140,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
128
140
  disabled={props.disabled}
129
141
  intent={semanticIntent}
130
142
  emphasis={semanticEmphasis}
143
+ inset={inset || undefined}
131
144
  size={semanticSize}
132
145
  onPress={(event) =>
133
146
  props.onClick?.(