@beyondwork/docx-react-component 1.0.41 → 1.0.43

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 (118) hide show
  1. package/package.json +38 -37
  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/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -22,28 +22,32 @@ import type {
22
22
  WorkflowCommentMarkup,
23
23
  } from "../api/public-types";
24
24
  import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
25
- import { searchSurfaceBlocks } from "../core/search/search-text.ts";
25
+ import {
26
+ projectSurfaceText,
27
+ searchProjectedSurfaceText,
28
+ } from "../core/search/search-text.ts";
26
29
  import { describeOpaqueFragment, isBlockedImportFeatureKey } from "../preservation/store.ts";
27
30
  import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
28
31
 
29
- export function collectWorkflowMarkupSnapshot(input: {
30
- renderSnapshot: RuntimeRenderSnapshot;
31
- fieldSnapshot: FieldSnapshot;
32
- protectionSnapshot: ProtectionSnapshot;
33
- preservation: CanonicalDocumentEnvelope["preservation"];
34
- workflowMetadataSnapshot?: WorkflowMetadataSnapshot;
35
- }): WorkflowMarkupSnapshot {
32
+ /**
33
+ * Surface-derived markup (highlights + block-level opaque fragments).
34
+ *
35
+ * Pure function of `(surface, preservation)` — extracted from
36
+ * `collectWorkflowMarkupSnapshot` so callers can cache the expensive walk
37
+ * separately from the cheap reference-equal inputs (metadata, comments,
38
+ * revisions, protected ranges).
39
+ */
40
+ export function collectWorkflowSurfaceMarkup(
41
+ surface: RuntimeRenderSnapshot["surface"],
42
+ preservation: CanonicalDocumentEnvelope["preservation"],
43
+ ): { highlights: WorkflowHighlightMarkup[]; opaqueFragments: WorkflowOpaqueFragmentMarkup[] } {
36
44
  const highlights: WorkflowHighlightMarkup[] = [];
37
- const metadata = collectWorkflowMetadataMarkup(input.workflowMetadataSnapshot);
38
- const fields: WorkflowFieldMarkup[] = [];
39
45
  const opaqueFragments: WorkflowOpaqueFragmentMarkup[] = [];
40
- const surface = input.renderSnapshot.surface;
41
-
42
46
  if (surface) {
43
47
  collectSurfaceMarkup(
44
48
  surface.blocks,
45
49
  MAIN_STORY_TARGET,
46
- input.preservation,
50
+ preservation,
47
51
  highlights,
48
52
  opaqueFragments,
49
53
  );
@@ -51,16 +55,55 @@ export function collectWorkflowMarkupSnapshot(input: {
51
55
  collectSurfaceMarkup(
52
56
  story.blocks,
53
57
  story.target,
54
- input.preservation,
58
+ preservation,
55
59
  highlights,
56
60
  opaqueFragments,
57
61
  );
58
62
  }
63
+ }
64
+ return { highlights, opaqueFragments };
65
+ }
59
66
 
67
+ export function collectWorkflowMarkupSnapshot(input: {
68
+ renderSnapshot: RuntimeRenderSnapshot;
69
+ fieldSnapshot: FieldSnapshot;
70
+ protectionSnapshot: ProtectionSnapshot;
71
+ preservation: CanonicalDocumentEnvelope["preservation"];
72
+ workflowMetadataSnapshot?: WorkflowMetadataSnapshot;
73
+ surfaceMarkupCache?: {
74
+ highlights: WorkflowHighlightMarkup[];
75
+ opaqueFragments: WorkflowOpaqueFragmentMarkup[];
76
+ };
77
+ perfStage?: (name: string, durationMs: number) => void;
78
+ }): WorkflowMarkupSnapshot {
79
+ const perf = input.perfStage;
80
+ const stageStart = perf ? () => performance.now() : () => 0;
81
+ const stageEnd = perf ? (name: string, t0: number) => perf(name, performance.now() - t0) : () => {};
82
+
83
+ const tMeta = stageStart();
84
+ const metadata = collectWorkflowMetadataMarkup(input.workflowMetadataSnapshot);
85
+ stageEnd("metadata", tMeta);
86
+
87
+ const fields: WorkflowFieldMarkup[] = [];
88
+ const surface = input.renderSnapshot.surface;
89
+
90
+ const tSurface = stageStart();
91
+ const surfaceMarkup = input.surfaceMarkupCache ??
92
+ collectWorkflowSurfaceMarkup(surface, input.preservation);
93
+ const highlights = surfaceMarkup.highlights.slice();
94
+ const opaqueFragments = surfaceMarkup.opaqueFragments.slice();
95
+ stageEnd("surface", tSurface);
96
+
97
+ if (surface) {
98
+ const tFields = stageStart();
60
99
  fields.push(...collectFieldMarkup(surface, input.fieldSnapshot));
100
+ stageEnd("fields", tFields);
61
101
  }
102
+ const tOpaqueRest = stageStart();
62
103
  opaqueFragments.push(...collectOpaqueFragmentMarkup(input.preservation, opaqueFragments));
104
+ stageEnd("opaqueRest", tOpaqueRest);
63
105
 
106
+ const tCommentsEtc = stageStart();
64
107
  const comments = input.renderSnapshot.comments.threads.map((thread): WorkflowCommentMarkup => ({
65
108
  markupId: `comment:${thread.commentId}`,
66
109
  kind: "comment",
@@ -113,6 +156,8 @@ export function collectWorkflowMarkupSnapshot(input: {
113
156
  }),
114
157
  );
115
158
 
159
+ stageEnd("commentsEtc", tCommentsEtc);
160
+
116
161
  const items: WorkflowMarkupItem[] = [
117
162
  ...highlights,
118
163
  ...metadata,
@@ -168,6 +213,15 @@ function collectWorkflowMetadataMarkup(
168
213
  value: entry.value,
169
214
  scopeId: entry.scopeId,
170
215
  workItemId: entry.workItemId,
216
+ // Schema 1.1 — copy external-mode fields through so card-layer code
217
+ // can detect external entries without re-consulting the entry snapshot.
218
+ ...(entry.metadataPersistence !== undefined
219
+ ? { metadataPersistence: entry.metadataPersistence }
220
+ : {}),
221
+ ...(entry.storageRef !== undefined ? { storageRef: entry.storageRef } : {}),
222
+ ...(entry.metadataVersion !== undefined
223
+ ? { metadataVersion: entry.metadataVersion }
224
+ : {}),
171
225
  } satisfies WorkflowMetadataMarkup];
172
226
  });
173
227
  }
@@ -294,11 +348,21 @@ function collectFieldMarkup(
294
348
  return [];
295
349
  }
296
350
 
351
+ // L7 Phase 1.5: project each story's text once up-front. The prior code
352
+ // called searchSurfaceBlocks per field, which re-projected the entire
353
+ // surface on every invocation. For the CCEP large-tables fixture this
354
+ // was ~220 ms per commit. Hoisting the projection out of the per-field
355
+ // loop collapses that to a single projection per story.
297
356
  const stories = [
298
- { blocks: surface.blocks, storyTarget: MAIN_STORY_TARGET },
357
+ {
358
+ blocks: surface.blocks,
359
+ storyTarget: MAIN_STORY_TARGET,
360
+ projection: projectSurfaceText(surface.blocks),
361
+ },
299
362
  ...surface.secondaryStories.map((story) => ({
300
363
  blocks: story.blocks,
301
364
  storyTarget: story.target,
365
+ projection: projectSurfaceText(story.blocks),
302
366
  })),
303
367
  ];
304
368
 
@@ -309,7 +373,7 @@ function collectFieldMarkup(
309
373
  }
310
374
 
311
375
  for (const story of stories) {
312
- const matches = searchSurfaceBlocks(story.blocks, displayText, { limit: 2 });
376
+ const matches = searchProjectedSurfaceText(story.projection, displayText, { limit: 2 });
313
377
  if (matches.length === 1) {
314
378
  const match = matches[0]!;
315
379
  return [
@@ -16,14 +16,18 @@ import type {
16
16
  EditorAnchorProjection,
17
17
  EditorStoryTarget,
18
18
  IssueMetadataValue,
19
+ ReviewActionMetadataValue,
19
20
  ScopeCardModel,
21
+ ScopeMetadataResolver,
22
+ SuggestionGroup,
23
+ SuggestionsSnapshot,
20
24
  WorkflowBlockedCommandReason,
21
25
  WorkflowCandidateRange,
22
26
  WorkflowLockedZone,
23
27
  WorkflowMetadataMarkup,
24
28
  WorkflowScope,
25
29
  } from "../api/public-types";
26
- import { ISSUE_METADATA_ID } from "../api/public-types";
30
+ import { ISSUE_METADATA_ID, REVIEW_ACTION_METADATA_ID } from "../api/public-types";
27
31
  import { MAIN_STORY_TARGET, storyTargetsEqual } from "../core/selection/mapping.ts";
28
32
  import type { RuntimePageGraph } from "./layout/page-graph.ts";
29
33
  import type {
@@ -316,6 +320,41 @@ export interface AttachScopeCardModelInput {
316
320
  * rects — chrome consumers fall back to on-render positioning.
317
321
  */
318
322
  anchorIndex?: RenderAnchorIndex | null;
323
+ /**
324
+ * R3 — suggestion snapshot. Groups whose `issueId` matches the
325
+ * scope's issue attach as `ScopeCardModel.suggestionGroups`; their
326
+ * ids land in `suggestionGroupIds` as a convenience for consumers
327
+ * that want a flat lookup.
328
+ */
329
+ suggestions?: SuggestionsSnapshot | null;
330
+ /**
331
+ * K1-light — review-action markup. Entries with
332
+ * `metadataId === REVIEW_ACTION_METADATA_ID` whose coerced value's
333
+ * `issueId` matches the scope's issue attach newest-first as
334
+ * `ScopeCardModel.reviewActions`.
335
+ */
336
+ reviewActionMetadata?: readonly WorkflowMetadataMarkup[];
337
+ /**
338
+ * K2 — candidate ranges. Any entry with `source: "ai"` whose
339
+ * offset range overlaps the segment flips `agentPending` to true.
340
+ */
341
+ candidates?: readonly WorkflowCandidateRange[];
342
+ /**
343
+ * Pre-resolved external metadata values, keyed by entryId. When an
344
+ * entry in `metadata` or `reviewActionMetadata` has
345
+ * `metadataPersistence === "external"` and its inline `value` is
346
+ * undefined, this map supplies the resolved value (fetched by the caller
347
+ * via `ScopeMetadataResolver.resolve`).
348
+ *
349
+ * Entries absent from the map render as if the metadata had no value —
350
+ * the card still shows the scope but drops the issue row, consistent with
351
+ * how malformed metadata is handled today.
352
+ *
353
+ * NOTE: The facet's `getAllScopeCardModels()` does NOT populate this.
354
+ * Callers that need resolved values build the map via
355
+ * `buildExternalResolutions()` and call `attachScopeCardModel()` directly.
356
+ */
357
+ externalResolutions?: ReadonlyMap<string, { value: Record<string, unknown>; version?: number }>;
319
358
  }
320
359
 
321
360
  /**
@@ -361,11 +400,23 @@ export function attachScopeCardModel(
361
400
  segment.scopeId,
362
401
  workItemId,
363
402
  input.metadata,
403
+ input.externalResolutions,
364
404
  );
365
405
  const primaryAnchorRect = input.anchorIndex
366
406
  ? input.anchorIndex.bySelection(segment.fromOffset, segment.toOffset)
367
407
  : null;
368
408
 
409
+ const suggestionGroups = resolveSuggestionGroupsForIssue(
410
+ issue?.issueId,
411
+ input.suggestions,
412
+ );
413
+ const reviewActions = resolveReviewActionsForIssue(
414
+ issue?.issueId,
415
+ input.reviewActionMetadata,
416
+ input.externalResolutions,
417
+ );
418
+ const agentPending = resolveAgentPending(segment, input.candidates);
419
+
369
420
  models.push({
370
421
  scopeId: segment.scopeId,
371
422
  ...(workItemId ? { workItemId } : {}),
@@ -373,19 +424,103 @@ export function attachScopeCardModel(
373
424
  posture: segment.posture,
374
425
  primaryAnchorRect,
375
426
  ...(issue ? { issue } : {}),
376
- suggestionGroupIds: [],
377
- reviewActionCount: 0,
378
- agentPending: false,
427
+ suggestionGroupIds: suggestionGroups.map((group) => group.groupId),
428
+ suggestionGroups,
429
+ reviewActionCount: reviewActions.length,
430
+ reviewActions,
431
+ agentPending,
379
432
  });
380
433
  }
381
434
 
382
435
  return models;
383
436
  }
384
437
 
438
+ function resolveSuggestionGroupsForIssue(
439
+ issueId: string | undefined,
440
+ suggestions: SuggestionsSnapshot | null | undefined,
441
+ ): SuggestionGroup[] {
442
+ if (!issueId || !suggestions?.groups) return [];
443
+ return suggestions.groups.filter((group) => group.issueId === issueId);
444
+ }
445
+
446
+ function resolveReviewActionsForIssue(
447
+ issueId: string | undefined,
448
+ metadata: readonly WorkflowMetadataMarkup[] | undefined,
449
+ externalResolutions?: ReadonlyMap<string, { value: Record<string, unknown>; version?: number }>,
450
+ ): ReviewActionMetadataValue[] {
451
+ if (!issueId || !metadata || metadata.length === 0) return [];
452
+ const result: ReviewActionMetadataValue[] = [];
453
+ for (const entry of metadata) {
454
+ if (entry.metadataId !== REVIEW_ACTION_METADATA_ID) continue;
455
+ const coerced = coerceReviewActionValue(effectiveMetadataValue(entry, externalResolutions));
456
+ if (!coerced) continue;
457
+ if (coerced.issueId !== issueId) continue;
458
+ result.push(coerced);
459
+ }
460
+ // Newest-first by createdAt ISO string (lexicographic for 8601 UTC).
461
+ result.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
462
+ return result;
463
+ }
464
+
465
+ function coerceReviewActionValue(
466
+ value: unknown,
467
+ ): ReviewActionMetadataValue | undefined {
468
+ if (!value || typeof value !== "object") return undefined;
469
+ const candidate = value as Partial<ReviewActionMetadataValue>;
470
+ if (
471
+ typeof candidate.reviewActionId !== "string" ||
472
+ typeof candidate.action !== "string" ||
473
+ typeof candidate.actor !== "string" ||
474
+ typeof candidate.actorRole !== "string" ||
475
+ typeof candidate.createdAt !== "string"
476
+ ) {
477
+ return undefined;
478
+ }
479
+ return candidate as ReviewActionMetadataValue;
480
+ }
481
+
482
+ function resolveAgentPending(
483
+ segment: ScopeRailSegment,
484
+ candidates: readonly WorkflowCandidateRange[] | undefined,
485
+ ): boolean {
486
+ if (!candidates || candidates.length === 0) return false;
487
+ for (const candidate of candidates) {
488
+ if (candidate.source !== "ai") continue;
489
+ const range = anchorToRuntimeRange(candidate.anchor);
490
+ if (!range) continue;
491
+ // Strict-half-open overlap: [from, to).
492
+ if (range.to > segment.fromOffset && range.from < segment.toOffset) {
493
+ return true;
494
+ }
495
+ }
496
+ return false;
497
+ }
498
+
499
+ /**
500
+ * Return the effective value for a metadata markup entry. For
501
+ * internal-mode entries (or entries with an inline value), returns
502
+ * `entry.value` directly. For external-mode entries whose inline
503
+ * `value` is undefined, looks up the resolved value from the
504
+ * caller-supplied `externalResolutions` map keyed by `entryId`.
505
+ *
506
+ * Returns `undefined` when no value is available — callers treat this the
507
+ * same as malformed metadata and drop the card row silently.
508
+ */
509
+ function effectiveMetadataValue(
510
+ entry: WorkflowMetadataMarkup,
511
+ externalResolutions?: ReadonlyMap<string, { value: Record<string, unknown>; version?: number }>,
512
+ ): Record<string, unknown> | undefined {
513
+ if (entry.value !== undefined) return entry.value;
514
+ // External-mode entry — inline body is empty. Look up the resolved value.
515
+ const resolved = externalResolutions?.get(entry.entryId);
516
+ return resolved?.value;
517
+ }
518
+
385
519
  function resolveIssueForScope(
386
520
  scopeId: string,
387
521
  workItemId: string | undefined,
388
522
  metadata: readonly WorkflowMetadataMarkup[] | undefined,
523
+ externalResolutions?: ReadonlyMap<string, { value: Record<string, unknown>; version?: number }>,
389
524
  ): IssueMetadataValue | undefined {
390
525
  if (!metadata || metadata.length === 0) return undefined;
391
526
  for (const entry of metadata) {
@@ -397,7 +532,7 @@ function resolveIssueForScope(
397
532
  const matchesScope =
398
533
  entry.scopeId !== undefined && entry.scopeId === scopeId;
399
534
  if (!matchesWorkItem && !matchesScope) continue;
400
- const coerced = coerceIssueValue(entry.value);
535
+ const coerced = coerceIssueValue(effectiveMetadataValue(entry, externalResolutions));
401
536
  if (coerced) return coerced;
402
537
  }
403
538
  return undefined;
@@ -426,3 +561,107 @@ function coerceIssueValue(
426
561
  }
427
562
  return candidate as IssueMetadataValue;
428
563
  }
564
+
565
+ // ---------------------------------------------------------------------------
566
+ // buildExternalResolutions — caller utility for pre-resolving external refs
567
+ // ---------------------------------------------------------------------------
568
+
569
+ /**
570
+ * Structured result from `buildExternalResolutions`. Callers that previously
571
+ * consumed the return value as a `Map` directly must now destructure `.resolutions`.
572
+ */
573
+ export interface BuildExternalResolutionsResult {
574
+ /**
575
+ * Resolved values keyed by `entryId`. Suitable for passing into
576
+ * `AttachScopeCardModelInput.externalResolutions`.
577
+ */
578
+ resolutions: Map<string, { value: Record<string, unknown>; version?: number }>;
579
+ /**
580
+ * Detected version mismatches. Non-empty only when the caller passes
581
+ * `detectConflicts: true`. Each record includes both sides' value +
582
+ * version so the caller can emit a `metadata_conflict_detected` event.
583
+ */
584
+ conflicts: Array<{
585
+ scopeId?: string;
586
+ entryId?: string;
587
+ embedded: { value?: Record<string, unknown>; version?: number } | null;
588
+ external: { value?: Record<string, unknown>; version?: number } | null;
589
+ }>;
590
+ }
591
+
592
+ /**
593
+ * Pre-resolve every external-mode metadata entry's value via the supplied
594
+ * resolver. The returned `resolutions` map is suitable for passing into
595
+ * `AttachScopeCardModelInput.externalResolutions`.
596
+ *
597
+ * Internal-mode entries (where `entry.value` is already inline) are skipped.
598
+ * When the resolver returns `undefined` for a ref (unknown or revoked), the
599
+ * `entryId` is omitted from the map; the scope card then renders without that
600
+ * value.
601
+ *
602
+ * Both `metadata` and `reviewActionMetadata` arrays are walked so review-action
603
+ * rows stored externally are resolved alongside issue entries.
604
+ *
605
+ * When `detectConflicts` is `true`, the helper compares each entry's embedded
606
+ * `metadataVersion` against the resolver-returned version. Any mismatch is
607
+ * recorded in the returned `conflicts` array.
608
+ *
609
+ * NOTE: This helper is intentionally async so callers can `await` it in the
610
+ * React layer before passing the map to the synchronous `attachScopeCardModel`.
611
+ * The layout facet's `getAllScopeCardModels()` does NOT call this — it is a
612
+ * React-layer concern where async is normal.
613
+ */
614
+ export async function buildExternalResolutions(input: {
615
+ metadata: readonly WorkflowMetadataMarkup[];
616
+ reviewActionMetadata?: readonly WorkflowMetadataMarkup[];
617
+ resolver: ScopeMetadataResolver;
618
+ /**
619
+ * When `true`, the helper compares each external entry's embedded
620
+ * `metadataVersion` against the resolver-returned version and records a
621
+ * conflict for every mismatch. Absent / `false` → `conflicts` is always
622
+ * empty (backwards-compatible default).
623
+ */
624
+ detectConflicts?: boolean;
625
+ }): Promise<BuildExternalResolutionsResult> {
626
+ const externalEntries = [
627
+ ...input.metadata,
628
+ ...(input.reviewActionMetadata ?? []),
629
+ ].filter(
630
+ (entry) =>
631
+ entry.metadataPersistence === "external" &&
632
+ entry.value === undefined &&
633
+ entry.storageRef !== undefined,
634
+ );
635
+
636
+ const results = await Promise.all(
637
+ externalEntries.map(async (entry) => {
638
+ const resolved = await input.resolver.resolve(entry.storageRef!);
639
+ return resolved ? { entry, resolved } : null;
640
+ }),
641
+ );
642
+
643
+ const resolutions = new Map<string, { value: Record<string, unknown>; version?: number }>();
644
+ const conflicts: BuildExternalResolutionsResult["conflicts"] = [];
645
+
646
+ for (const row of results) {
647
+ if (!row) continue;
648
+ resolutions.set(row.entry.entryId, row.resolved);
649
+
650
+ if (
651
+ input.detectConflicts &&
652
+ row.entry.metadataVersion !== undefined &&
653
+ row.resolved.version !== undefined &&
654
+ row.entry.metadataVersion !== row.resolved.version
655
+ ) {
656
+ conflicts.push({
657
+ scopeId: row.entry.scopeId,
658
+ entryId: row.entry.entryId,
659
+ // External entries have no inline value — embedded side carries only version
660
+ embedded: { version: row.entry.metadataVersion },
661
+ external: { value: row.resolved.value, version: row.resolved.version },
662
+ });
663
+ }
664
+ }
665
+
666
+ return { resolutions, conflicts };
667
+ }