@beyondwork/docx-react-component 1.0.40 → 1.0.42

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 (88) hide show
  1. package/package.json +13 -1
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
@@ -32,11 +32,14 @@ import type {
32
32
  InteractionGuardSnapshot,
33
33
  InsertImageOptions,
34
34
  InsertTableOptions,
35
+ MetadataPersistenceMode,
35
36
  PageLayoutSnapshot,
36
37
  PersistedEditorSnapshot,
38
+ ResolveMetadataConflictInput,
37
39
  ReviewQueueItem,
38
40
  ReviewQueueSnapshot,
39
41
  RuntimeRenderSnapshot,
42
+ ScopeMetadataPersistence,
40
43
  SectionBreakType,
41
44
  SectionLayoutPatch,
42
45
  SectionPageNumberingPatch,
@@ -71,6 +74,8 @@ import type {
71
74
  WorkspaceMode,
72
75
  ZoomLevel,
73
76
  } from "../api/public-types";
77
+ import { MetadataResolverMissingError } from "../api/public-types";
78
+ import type { ScopeMetadataResolver } from "../api/scope-metadata-resolver-types.ts";
74
79
  import {
75
80
  editorSessionStateFromPersistedSnapshot,
76
81
  persistedSnapshotFromEditorSessionState,
@@ -254,10 +259,219 @@ type SelectionToolbarDismissReason =
254
259
  | "comment-action"
255
260
  | "escape";
256
261
 
262
+ // ---------------------------------------------------------------------------
263
+ // P17 module-level helpers — metadata persistence
264
+ // ---------------------------------------------------------------------------
265
+
266
+ function conflictKey(input: {
267
+ scopeId?: string;
268
+ entryId?: string;
269
+ fieldKey?: string;
270
+ }): string {
271
+ return `${input.scopeId ?? ""}|${input.entryId ?? ""}|${input.fieldKey ?? ""}`;
272
+ }
273
+
274
+ function resolveEffective(input: {
275
+ overlay?: MetadataPersistenceMode;
276
+ scope?: ScopeMetadataPersistence;
277
+ field?: ScopeMetadataPersistence;
278
+ }): "internal" | "external" {
279
+ if (input.field === "internal" || input.field === "external") return input.field;
280
+ if (input.scope === "internal" || input.scope === "external") return input.scope;
281
+ return input.overlay ?? "internal";
282
+ }
283
+
284
+ /**
285
+ * A pending conflict record held in `metadataConflictPendingRef` while
286
+ * the host decides how to resolve. Keyed by `conflictKey(...)`.
287
+ */
288
+ interface PendingConflict {
289
+ scopeId?: string;
290
+ entryId?: string;
291
+ fieldKey?: string;
292
+ embedded: { value?: Record<string, unknown>; version?: number } | null;
293
+ external: { value?: Record<string, unknown>; version?: number } | null;
294
+ defaultPolicy: "prefer-latest";
295
+ }
296
+
297
+ /** Emit a single `metadata_conflict_detected` event and register the pending conflict. */
298
+ function registerAndEmitConflict(args: {
299
+ onEvent: ((event: WordReviewEditorEvent) => void) | undefined;
300
+ documentId: string;
301
+ conflict: PendingConflict;
302
+ pendingConflicts: Map<string, PendingConflict>;
303
+ }): void {
304
+ const key = conflictKey(args.conflict);
305
+ // Guard: do not emit duplicate events for the same key in a single pass.
306
+ if (args.pendingConflicts.has(key)) return;
307
+ args.pendingConflicts.set(key, args.conflict);
308
+ args.onEvent?.({
309
+ type: "metadata_conflict_detected",
310
+ documentId: args.documentId,
311
+ scopeId: args.conflict.scopeId,
312
+ entryId: args.conflict.entryId,
313
+ fieldKey: args.conflict.fieldKey,
314
+ embedded: args.conflict.embedded,
315
+ external: args.conflict.external,
316
+ defaultPolicy: args.conflict.defaultPolicy,
317
+ });
318
+ }
319
+
320
+ async function runConvertScopesToInternal(args: {
321
+ runtime: WordReviewEditorRuntime;
322
+ scopeIds: string[];
323
+ resolver: ScopeMetadataResolver | null;
324
+ /** When provided, version mismatches emit `metadata_conflict_detected`. */
325
+ documentId?: string;
326
+ onEvent?: (event: WordReviewEditorEvent) => void;
327
+ pendingConflicts?: Map<string, PendingConflict>;
328
+ }): Promise<void> {
329
+ if (!args.resolver) throw new MetadataResolverMissingError();
330
+ const snapshot = args.runtime.getWorkflowMetadataSnapshot();
331
+ const overlay = args.runtime.getWorkflowOverlay();
332
+
333
+ const nextEntries = await Promise.all(
334
+ snapshot.entries.map(async (entry) => {
335
+ if (!entry.scopeId || !args.scopeIds.includes(entry.scopeId)) return entry;
336
+ const scope = overlay?.scopes.find((s) => s.scopeId === entry.scopeId);
337
+ const effective = resolveEffective({
338
+ overlay: overlay?.metadataPersistence,
339
+ scope: scope?.metadataPersistence,
340
+ field: entry.metadataPersistence,
341
+ });
342
+ if (effective !== "external" || !entry.storageRef) return entry;
343
+ const resolved = await args.resolver!.resolve(entry.storageRef);
344
+ if (!resolved) return entry;
345
+
346
+ // Conflict detection: compare embedded metadataVersion vs resolver version.
347
+ if (
348
+ args.pendingConflicts &&
349
+ args.documentId !== undefined &&
350
+ entry.metadataVersion !== undefined &&
351
+ resolved.version !== undefined &&
352
+ entry.metadataVersion !== resolved.version
353
+ ) {
354
+ registerAndEmitConflict({
355
+ onEvent: args.onEvent,
356
+ documentId: args.documentId,
357
+ pendingConflicts: args.pendingConflicts,
358
+ conflict: {
359
+ scopeId: entry.scopeId,
360
+ entryId: entry.entryId,
361
+ // External entries have no inline value; embedded side carries version only.
362
+ embedded: { version: entry.metadataVersion },
363
+ external: { value: resolved.value, version: resolved.version },
364
+ defaultPolicy: "prefer-latest",
365
+ },
366
+ });
367
+ }
368
+
369
+ // Default behavior: always apply the resolved value (host can override via resolveMetadataConflict).
370
+ return {
371
+ ...entry,
372
+ value: resolved.value,
373
+ metadataPersistence: "internal" as const,
374
+ storageRef: undefined,
375
+ metadataVersion: resolved.version ?? entry.metadataVersion,
376
+ };
377
+ }),
378
+ );
379
+
380
+ args.runtime.setWorkflowMetadataEntries(nextEntries);
381
+ }
382
+
383
+ async function runConvertScopesToExternal(args: {
384
+ runtime: WordReviewEditorRuntime;
385
+ scopeIds: string[];
386
+ resolver: ScopeMetadataResolver | null;
387
+ /** When provided, version-conflict publish errors emit `metadata_conflict_detected`. */
388
+ documentId?: string;
389
+ onEvent?: (event: WordReviewEditorEvent) => void;
390
+ pendingConflicts?: Map<string, PendingConflict>;
391
+ }): Promise<void> {
392
+ if (!args.resolver) throw new MetadataResolverMissingError();
393
+ const snapshot = args.runtime.getWorkflowMetadataSnapshot();
394
+ const overlay = args.runtime.getWorkflowOverlay();
395
+
396
+ const nextEntries = await Promise.all(
397
+ snapshot.entries.map(async (entry) => {
398
+ if (!entry.scopeId || !args.scopeIds.includes(entry.scopeId)) return entry;
399
+ const scope = overlay?.scopes.find((s) => s.scopeId === entry.scopeId);
400
+ const effective = resolveEffective({
401
+ overlay: overlay?.metadataPersistence,
402
+ scope: scope?.metadataPersistence,
403
+ field: entry.metadataPersistence,
404
+ });
405
+ if (effective === "external") return entry;
406
+
407
+ try {
408
+ const { ref, version } = await args.resolver!.publish({
409
+ scopeId: entry.scopeId,
410
+ metadataId: entry.metadataId,
411
+ entryId: entry.entryId,
412
+ value: entry.value ?? {},
413
+ expectedVersion: entry.metadataVersion,
414
+ });
415
+ return {
416
+ ...entry,
417
+ value: undefined,
418
+ metadataPersistence: "external" as const,
419
+ storageRef: ref,
420
+ metadataVersion: version,
421
+ };
422
+ } catch (err: unknown) {
423
+ // Duck-type version-conflict errors (HarnessVersionConflictError or compatible shapes).
424
+ if (
425
+ args.pendingConflicts &&
426
+ args.documentId !== undefined &&
427
+ err instanceof Error &&
428
+ (err.name === "HarnessVersionConflictError" ||
429
+ ("ref" in err && "expected" in err && "actual" in err))
430
+ ) {
431
+ const conflictErr = err as Error & { ref?: string; expected?: number; actual?: number };
432
+ registerAndEmitConflict({
433
+ onEvent: args.onEvent,
434
+ documentId: args.documentId,
435
+ pendingConflicts: args.pendingConflicts,
436
+ conflict: {
437
+ scopeId: entry.scopeId,
438
+ entryId: entry.entryId,
439
+ // Embedded side: the entry's current inline value and metadataVersion.
440
+ embedded: { value: entry.value, version: entry.metadataVersion },
441
+ // External side: only the rowstore's actual version (no value available from error).
442
+ external: { version: conflictErr.actual },
443
+ defaultPolicy: "prefer-latest",
444
+ },
445
+ });
446
+ // Skip this entry — do not publish; leave it unchanged.
447
+ return entry;
448
+ }
449
+ // Non-conflict errors propagate normally.
450
+ throw err;
451
+ }
452
+ }),
453
+ );
454
+
455
+ args.runtime.setWorkflowMetadataEntries(nextEntries);
456
+ }
457
+
458
+ // ---------------------------------------------------------------------------
459
+
257
460
  export function __createWordReviewEditorRefBridge(
258
461
  runtime: WordReviewEditorRuntime,
259
462
  mountedSurface?: TwProseMirrorSurfaceRef | null,
463
+ options?: {
464
+ documentId?: string;
465
+ onEvent?: (event: WordReviewEditorEvent) => void;
466
+ resolverRef?: { current: ScopeMetadataResolver | null };
467
+ conflictResolutionsRef?: {
468
+ current: Map<string, { choice: string; mergedValue?: Record<string, unknown> }>;
469
+ };
470
+ },
260
471
  ): WordReviewEditorRef {
472
+ // Pending-conflict queue for this bridge instance. Keyed by conflictKey(...).
473
+ const pendingConflicts = new Map<string, PendingConflict>();
474
+
261
475
  return {
262
476
  focus: () => runtime.focus(),
263
477
  blur: () => runtime.blur(),
@@ -277,6 +491,10 @@ export function __createWordReviewEditorRefBridge(
277
491
  rejectChange: (changeId) => runtime.rejectChange(changeId),
278
492
  acceptAllChanges: () => runtime.acceptAllChanges(),
279
493
  rejectAllChanges: () => runtime.rejectAllChanges(),
494
+ acceptSuggestionGroup: (groupId) =>
495
+ applySuggestionGroupAction(runtime, groupId, "accept"),
496
+ rejectSuggestionGroup: (groupId) =>
497
+ applySuggestionGroupAction(runtime, groupId, "reject"),
280
498
  exportDocx: (options) => runtime.exportDocx(options),
281
499
  getSessionState: () => runtime.getSessionState(),
282
500
  getSnapshot: () => runtime.getPersistedSnapshot(),
@@ -575,6 +793,151 @@ export function __createWordReviewEditorRefBridge(
575
793
  getWorkflowMetadataSnapshot: () => {
576
794
  return clonePublicValue(runtime.getWorkflowMetadataSnapshot());
577
795
  },
796
+ // P17 — metadata persistence toggle + convert methods.
797
+ setMetadataPersistenceMode: (mode) => {
798
+ if (mode === "external" && !(options?.resolverRef?.current ?? null)) {
799
+ throw new MetadataResolverMissingError();
800
+ }
801
+ const overlay = runtime.getWorkflowOverlay();
802
+ if (!overlay) return;
803
+ runtime.setWorkflowOverlay({ ...overlay, metadataPersistence: mode });
804
+ options?.onEvent?.({
805
+ type: "metadata_persistence_mode_changed",
806
+ documentId: options?.documentId ?? "",
807
+ mode,
808
+ });
809
+ },
810
+ getMetadataPersistenceMode: () => {
811
+ return runtime.getWorkflowOverlay()?.metadataPersistence ?? "internal";
812
+ },
813
+ setScopeMetadataPersistence: (scopeId, persistence) => {
814
+ if (persistence === "external" && !(options?.resolverRef?.current ?? null)) {
815
+ throw new MetadataResolverMissingError();
816
+ }
817
+ const overlay = runtime.getWorkflowOverlay();
818
+ if (!overlay) return;
819
+ const nextOverlay = {
820
+ ...overlay,
821
+ scopes: overlay.scopes.map((s) => {
822
+ if (s.scopeId !== scopeId) return s;
823
+ if (persistence === "inherit") {
824
+ const { metadataPersistence: _, ...rest } = s;
825
+ return rest as typeof s;
826
+ }
827
+ return { ...s, metadataPersistence: persistence };
828
+ }),
829
+ };
830
+ runtime.setWorkflowOverlay(nextOverlay);
831
+ options?.onEvent?.({
832
+ type: "scope_metadata_persistence_changed",
833
+ documentId: options?.documentId ?? "",
834
+ scopeId,
835
+ persistence,
836
+ });
837
+ },
838
+ getScopeMetadataPersistence: (scopeId) => {
839
+ const overlay = runtime.getWorkflowOverlay();
840
+ return overlay?.scopes.find((s) => s.scopeId === scopeId)?.metadataPersistence ?? "inherit";
841
+ },
842
+ setAllScopesMetadataPersistence: (persistence) => {
843
+ if (persistence === "external" && !(options?.resolverRef?.current ?? null)) {
844
+ throw new MetadataResolverMissingError();
845
+ }
846
+ const overlay = runtime.getWorkflowOverlay();
847
+ if (!overlay) return;
848
+ const nextOverlay = {
849
+ ...overlay,
850
+ scopes: overlay.scopes.map((s) => {
851
+ if (persistence === "inherit") {
852
+ const { metadataPersistence: _, ...rest } = s;
853
+ return rest as typeof s;
854
+ }
855
+ return { ...s, metadataPersistence: persistence };
856
+ }),
857
+ };
858
+ runtime.setWorkflowOverlay(nextOverlay);
859
+ options?.onEvent?.({
860
+ type: "scope_metadata_persistence_changed",
861
+ documentId: options?.documentId ?? "",
862
+ scopeId: "*",
863
+ persistence,
864
+ });
865
+ },
866
+ setScopeMetadataResolver: (resolver) => {
867
+ if (options?.resolverRef) {
868
+ options.resolverRef.current = resolver;
869
+ }
870
+ },
871
+ resolveMetadataConflict: (input: ResolveMetadataConflictInput) => {
872
+ // Legacy: keep conflictResolutionsRef updated so Task 8 tests pass.
873
+ if (options?.conflictResolutionsRef) {
874
+ options.conflictResolutionsRef.current.set(conflictKey(input), {
875
+ choice: input.choice,
876
+ mergedValue: input.mergedValue,
877
+ });
878
+ }
879
+
880
+ // New: apply the chosen value to the metadata snapshot.
881
+ const pending = pendingConflicts.get(conflictKey(input));
882
+ if (!pending) return; // No pending conflict — idempotent no-op.
883
+
884
+ let finalValue: Record<string, unknown> | undefined;
885
+ if (input.choice === "embedded") {
886
+ finalValue = pending.embedded?.value;
887
+ } else if (input.choice === "external") {
888
+ finalValue = pending.external?.value;
889
+ } else if (input.choice === "merge") {
890
+ finalValue = input.mergedValue;
891
+ }
892
+
893
+ if (finalValue === undefined) {
894
+ // Nothing to write (e.g., embedded side has no inline value).
895
+ pendingConflicts.delete(conflictKey(input));
896
+ return;
897
+ }
898
+
899
+ const winnerVersion =
900
+ input.choice === "external"
901
+ ? pending.external?.version ?? undefined
902
+ : input.choice === "embedded"
903
+ ? pending.embedded?.version ?? undefined
904
+ : undefined;
905
+
906
+ const snapshot = runtime.getWorkflowMetadataSnapshot();
907
+ const nextEntries = snapshot.entries.map((entry) => {
908
+ if (input.entryId && entry.entryId !== input.entryId) return entry;
909
+ if (input.scopeId && entry.scopeId !== input.scopeId) return entry;
910
+ return {
911
+ ...entry,
912
+ value: finalValue,
913
+ metadataPersistence: "internal" as const,
914
+ storageRef: undefined,
915
+ metadataVersion: winnerVersion ?? entry.metadataVersion,
916
+ };
917
+ });
918
+ runtime.setWorkflowMetadataEntries(nextEntries);
919
+ pendingConflicts.delete(conflictKey(input));
920
+ },
921
+ convertScopesToInternal: async (scopeIds) => {
922
+ await runConvertScopesToInternal({
923
+ runtime,
924
+ scopeIds,
925
+ resolver: options?.resolverRef?.current ?? null,
926
+ documentId: options?.documentId,
927
+ onEvent: options?.onEvent,
928
+ pendingConflicts,
929
+ });
930
+ },
931
+ convertScopesToExternal: async (scopeIds) => {
932
+ await runConvertScopesToExternal({
933
+ runtime,
934
+ scopeIds,
935
+ resolver: options?.resolverRef?.current ?? null,
936
+ documentId: options?.documentId,
937
+ onEvent: options?.onEvent,
938
+ pendingConflicts,
939
+ });
940
+ },
578
941
  setHostAnnotationOverlay: (overlay) => {
579
942
  runtime.setHostAnnotationOverlay(clonePublicValue(overlay));
580
943
  },
@@ -687,6 +1050,11 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
687
1050
  const shellRef = useRef<HTMLDivElement | null>(null);
688
1051
  const lastSelectionToolbarKeyRef = useRef<string | null>(null);
689
1052
  const lastAnnouncedErrorIdRef = useRef<string | null>(null);
1053
+ const scopeMetadataResolverRef = useRef<ScopeMetadataResolver | null>(null);
1054
+ const metadataConflictResolutionsRef = useRef(
1055
+ new Map<string, { choice: string; mergedValue?: Record<string, unknown> }>(),
1056
+ );
1057
+ const metadataConflictPendingRef = useRef(new Map<string, PendingConflict>());
690
1058
  const {
691
1059
  runtime,
692
1060
  loadError,
@@ -1094,6 +1462,10 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1094
1462
  rejectChange: (changeId) => activeRuntime.rejectChange(changeId),
1095
1463
  acceptAllChanges: () => activeRuntime.acceptAllChanges(),
1096
1464
  rejectAllChanges: () => activeRuntime.rejectAllChanges(),
1465
+ acceptSuggestionGroup: (groupId) =>
1466
+ applySuggestionGroupAction(activeRuntime, groupId, "accept"),
1467
+ rejectSuggestionGroup: (groupId) =>
1468
+ applySuggestionGroupAction(activeRuntime, groupId, "reject"),
1097
1469
  exportDocx: (options) =>
1098
1470
  runtime
1099
1471
  ? persistAndExportFromBoundary({
@@ -1446,6 +1818,149 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1446
1818
  getWorkflowMetadataSnapshot: () => {
1447
1819
  return clonePublicValue(activeRuntime.getWorkflowMetadataSnapshot());
1448
1820
  },
1821
+ // P17 — metadata persistence toggle + convert methods.
1822
+ setMetadataPersistenceMode: (mode) => {
1823
+ if (mode === "external" && scopeMetadataResolverRef.current === null) {
1824
+ throw new MetadataResolverMissingError();
1825
+ }
1826
+ const overlay = activeRuntime.getWorkflowOverlay();
1827
+ if (!overlay) return;
1828
+ activeRuntime.setWorkflowOverlay({ ...overlay, metadataPersistence: mode });
1829
+ onEventRef.current?.({
1830
+ type: "metadata_persistence_mode_changed",
1831
+ documentId,
1832
+ mode,
1833
+ });
1834
+ },
1835
+ getMetadataPersistenceMode: () => {
1836
+ return activeRuntime.getWorkflowOverlay()?.metadataPersistence ?? "internal";
1837
+ },
1838
+ setScopeMetadataPersistence: (scopeId, persistence) => {
1839
+ if (persistence === "external" && scopeMetadataResolverRef.current === null) {
1840
+ throw new MetadataResolverMissingError();
1841
+ }
1842
+ const overlay = activeRuntime.getWorkflowOverlay();
1843
+ if (!overlay) return;
1844
+ const nextOverlay = {
1845
+ ...overlay,
1846
+ scopes: overlay.scopes.map((s) => {
1847
+ if (s.scopeId !== scopeId) return s;
1848
+ if (persistence === "inherit") {
1849
+ const { metadataPersistence: _, ...rest } = s;
1850
+ return rest as typeof s;
1851
+ }
1852
+ return { ...s, metadataPersistence: persistence };
1853
+ }),
1854
+ };
1855
+ activeRuntime.setWorkflowOverlay(nextOverlay);
1856
+ onEventRef.current?.({
1857
+ type: "scope_metadata_persistence_changed",
1858
+ documentId,
1859
+ scopeId,
1860
+ persistence,
1861
+ });
1862
+ },
1863
+ getScopeMetadataPersistence: (scopeId) => {
1864
+ const overlay = activeRuntime.getWorkflowOverlay();
1865
+ return (
1866
+ overlay?.scopes.find((s) => s.scopeId === scopeId)?.metadataPersistence ?? "inherit"
1867
+ );
1868
+ },
1869
+ setAllScopesMetadataPersistence: (persistence) => {
1870
+ if (persistence === "external" && scopeMetadataResolverRef.current === null) {
1871
+ throw new MetadataResolverMissingError();
1872
+ }
1873
+ const overlay = activeRuntime.getWorkflowOverlay();
1874
+ if (!overlay) return;
1875
+ const nextOverlay = {
1876
+ ...overlay,
1877
+ scopes: overlay.scopes.map((s) => {
1878
+ if (persistence === "inherit") {
1879
+ const { metadataPersistence: _, ...rest } = s;
1880
+ return rest as typeof s;
1881
+ }
1882
+ return { ...s, metadataPersistence: persistence };
1883
+ }),
1884
+ };
1885
+ activeRuntime.setWorkflowOverlay(nextOverlay);
1886
+ onEventRef.current?.({
1887
+ type: "scope_metadata_persistence_changed",
1888
+ documentId,
1889
+ scopeId: "*",
1890
+ persistence,
1891
+ });
1892
+ },
1893
+ setScopeMetadataResolver: (resolver) => {
1894
+ scopeMetadataResolverRef.current = resolver;
1895
+ },
1896
+ resolveMetadataConflict: (input) => {
1897
+ // Legacy: keep metadataConflictResolutionsRef updated so Task 8 tests pass.
1898
+ metadataConflictResolutionsRef.current.set(conflictKey(input), {
1899
+ choice: input.choice,
1900
+ mergedValue: input.mergedValue,
1901
+ });
1902
+
1903
+ // New: apply the chosen value to the metadata snapshot.
1904
+ const pending = metadataConflictPendingRef.current.get(conflictKey(input));
1905
+ if (!pending) return; // No pending conflict — idempotent no-op.
1906
+
1907
+ let finalValue: Record<string, unknown> | undefined;
1908
+ if (input.choice === "embedded") {
1909
+ finalValue = pending.embedded?.value;
1910
+ } else if (input.choice === "external") {
1911
+ finalValue = pending.external?.value;
1912
+ } else if (input.choice === "merge") {
1913
+ finalValue = input.mergedValue;
1914
+ }
1915
+
1916
+ if (finalValue === undefined) {
1917
+ // Nothing to write (e.g., embedded side has no inline value).
1918
+ metadataConflictPendingRef.current.delete(conflictKey(input));
1919
+ return;
1920
+ }
1921
+
1922
+ const winnerVersion =
1923
+ input.choice === "external"
1924
+ ? pending.external?.version ?? undefined
1925
+ : input.choice === "embedded"
1926
+ ? pending.embedded?.version ?? undefined
1927
+ : undefined;
1928
+
1929
+ const snapshot = activeRuntime.getWorkflowMetadataSnapshot();
1930
+ const nextEntries = snapshot.entries.map((entry) => {
1931
+ if (input.entryId && entry.entryId !== input.entryId) return entry;
1932
+ if (input.scopeId && entry.scopeId !== input.scopeId) return entry;
1933
+ return {
1934
+ ...entry,
1935
+ value: finalValue,
1936
+ metadataPersistence: "internal" as const,
1937
+ storageRef: undefined,
1938
+ metadataVersion: winnerVersion ?? entry.metadataVersion,
1939
+ };
1940
+ });
1941
+ activeRuntime.setWorkflowMetadataEntries(nextEntries);
1942
+ metadataConflictPendingRef.current.delete(conflictKey(input));
1943
+ },
1944
+ convertScopesToInternal: async (scopeIds) => {
1945
+ await runConvertScopesToInternal({
1946
+ runtime: activeRuntime,
1947
+ scopeIds,
1948
+ resolver: scopeMetadataResolverRef.current,
1949
+ documentId,
1950
+ onEvent: onEventRef.current,
1951
+ pendingConflicts: metadataConflictPendingRef.current,
1952
+ });
1953
+ },
1954
+ convertScopesToExternal: async (scopeIds) => {
1955
+ await runConvertScopesToExternal({
1956
+ runtime: activeRuntime,
1957
+ scopeIds,
1958
+ resolver: scopeMetadataResolverRef.current,
1959
+ documentId,
1960
+ onEvent: onEventRef.current,
1961
+ pendingConflicts: metadataConflictPendingRef.current,
1962
+ });
1963
+ },
1449
1964
  setHostAnnotationOverlay: (overlay) => {
1450
1965
  setHostAnnotationOverlayState(clonePublicValue(overlay));
1451
1966
  },
@@ -2450,11 +2965,83 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2450
2965
  action: payload.action,
2451
2966
  });
2452
2967
  }}
2968
+ onScopeAcceptSuggestionGroup={(payload) => {
2969
+ applySuggestionGroupAction(activeRuntime, payload.groupId, "accept");
2970
+ }}
2971
+ onScopeRejectSuggestionGroup={(payload) => {
2972
+ applySuggestionGroupAction(activeRuntime, payload.groupId, "reject");
2973
+ }}
2974
+ onScopeAskAgent={(payload) => {
2975
+ // Resolve the scope's anchor + story from the facet's card
2976
+ // model so the agent request carries the canonical range.
2977
+ const facet = activeRuntime.layout;
2978
+ const models =
2979
+ facet && typeof facet.getAllScopeCardModels === "function"
2980
+ ? facet.getAllScopeCardModels()
2981
+ : [];
2982
+ const model = models.find((entry) => entry.scopeId === payload.scopeId);
2983
+ if (!model) return;
2984
+ const scopeSnapshot = activeRuntime.getWorkflowScopeSnapshot();
2985
+ const scopes = scopeSnapshot?.scopes ?? [];
2986
+ const scope = scopes.find((entry) => entry.scopeId === payload.scopeId);
2987
+ if (!scope) return;
2988
+ const anchor = scope.anchor;
2989
+ if (!anchor) return;
2990
+ const requestId =
2991
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
2992
+ ? `req-${crypto.randomUUID()}`
2993
+ : `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
2994
+ const eventPayload: Extract<
2995
+ WordReviewEditorEvent,
2996
+ { type: "agent-on-selection-requested" }
2997
+ > = {
2998
+ type: "agent-on-selection-requested",
2999
+ documentId,
3000
+ requestId,
3001
+ scopeId: payload.scopeId,
3002
+ anchor,
3003
+ selectionText: model.label ?? "",
3004
+ ...(scope.storyTarget ? { storyTarget: scope.storyTarget } : {}),
3005
+ };
3006
+ onEventRef.current?.(eventPayload);
3007
+ }}
2453
3008
  />
2454
3009
  );
2455
3010
  },
2456
3011
  );
2457
3012
 
3013
+ /**
3014
+ * R3 — best-effort suggestion-group accept/reject fan-out. Resolves
3015
+ * the group's suggestions from the current snapshot, then fans out
3016
+ * `acceptChange` / `rejectChange` across every changeId in each
3017
+ * group member. P2 batches these in rapid succession; the runtime
3018
+ * commit boundary collapses them into a single logical transaction.
3019
+ * A future phase adds true atomicity at the runtime level.
3020
+ */
3021
+ function applySuggestionGroupAction(
3022
+ runtime: WordReviewEditorRuntime,
3023
+ groupId: string,
3024
+ action: "accept" | "reject",
3025
+ ): void {
3026
+ const snapshot = runtime.getSuggestionsSnapshot();
3027
+ const group = snapshot.groups?.find((entry) => entry.groupId === groupId);
3028
+ if (!group) return;
3029
+ const byId = new Map(
3030
+ snapshot.suggestions.map((entry) => [entry.suggestionId, entry]),
3031
+ );
3032
+ for (const suggestionId of group.suggestionIds) {
3033
+ const suggestion = byId.get(suggestionId);
3034
+ if (!suggestion) continue;
3035
+ for (const changeId of suggestion.changeIds) {
3036
+ if (action === "accept") {
3037
+ runtime.acceptChange(changeId);
3038
+ } else {
3039
+ runtime.rejectChange(changeId);
3040
+ }
3041
+ }
3042
+ }
3043
+ }
3044
+
2458
3045
  function applyRuntimeFormattingOperation(
2459
3046
  runtime: WordReviewEditorRuntime,
2460
3047
  operation:
@@ -868,6 +868,7 @@ function createLoadingRuntimeBridge(input: {
868
868
  Promise.reject(createLoadingBoundaryError(input.snapshot.documentId, "export")),
869
869
  setWorkflowOverlay: () => undefined,
870
870
  clearWorkflowOverlay: () => undefined,
871
+ getWorkflowOverlay: () => null,
871
872
  getWorkflowScopeSnapshot: () => null,
872
873
  getInteractionGuardSnapshot: () => ({ effectiveMode: "edit", blockedReasons: [] }),
873
874
  getWorkflowMarkupSnapshot: () => ({
@@ -105,6 +105,17 @@ export interface EditorShellViewProps {
105
105
  issueId: string;
106
106
  action: import("../api/public-types.ts").ScopeIssueAction;
107
107
  }) => void;
108
+ /** R3 — forwarded from workspace to WordReviewEditor. */
109
+ onScopeAcceptSuggestionGroup?: (payload: {
110
+ scopeId: string;
111
+ groupId: string;
112
+ }) => void;
113
+ onScopeRejectSuggestionGroup?: (payload: {
114
+ scopeId: string;
115
+ groupId: string;
116
+ }) => void;
117
+ /** K2 — forwarded from workspace to WordReviewEditor. */
118
+ onScopeAskAgent?: (payload: { scopeId: string }) => void;
108
119
  }
109
120
 
110
121
  export function EditorShellView(props: EditorShellViewProps) {