@beyondwork/docx-react-component 1.0.83 → 1.0.85

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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/api/internal/build-ref-projections.ts +3 -0
  3. package/src/api/public-types.ts +86 -4
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/runtime/content.ts +148 -1
  6. package/src/api/v3/runtime/formatting.ts +41 -0
  7. package/src/api/v3/runtime/review.ts +98 -0
  8. package/src/api/v3/runtime/workflow.ts +154 -6
  9. package/src/core/commands/index.ts +81 -25
  10. package/src/core/state/editor-state.ts +15 -0
  11. package/src/io/export/serialize-main-document.ts +72 -6
  12. package/src/io/ooxml/header-footer-reference.ts +38 -0
  13. package/src/io/ooxml/parse-headers-footers.ts +11 -23
  14. package/src/io/ooxml/parse-main-document.ts +7 -10
  15. package/src/io/ooxml/workflow-payload-validator.ts +24 -0
  16. package/src/io/ooxml/workflow-payload.ts +12 -0
  17. package/src/model/canonical-document.ts +9 -0
  18. package/src/model/review/comment-types.ts +2 -0
  19. package/src/runtime/document-runtime.ts +718 -68
  20. package/src/runtime/formatting/field/resolver.ts +73 -8
  21. package/src/runtime/layout/layout-engine-version.ts +31 -12
  22. package/src/runtime/layout/paginated-layout-engine.ts +18 -11
  23. package/src/runtime/layout/public-facet.ts +119 -16
  24. package/src/runtime/layout/resolve-page-fields.ts +68 -6
  25. package/src/runtime/layout/resolve-page-previews.ts +1 -1
  26. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  27. package/src/runtime/scopes/action-validation.ts +54 -45
  28. package/src/runtime/scopes/workflow-overlap.ts +41 -9
  29. package/src/runtime/suggestions-snapshot.ts +24 -0
  30. package/src/runtime/surface-projection.ts +59 -2
  31. package/src/runtime/workflow/coordinator.ts +66 -14
  32. package/src/runtime/workflow/scope-writer.ts +83 -5
  33. package/src/shell/ref-commands.ts +3 -354
  34. package/src/shell/session-bootstrap.ts +10 -0
  35. package/src/ui/WordReviewEditor.tsx +99 -9
  36. package/src/ui/editor-command-bag.ts +3 -1
  37. package/src/ui/headless/revision-decoration-model.ts +13 -0
  38. package/src/ui/headless/selection-tool-types.ts +2 -0
  39. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +7 -3
  40. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +175 -25
  41. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -1
  42. package/src/ui-tailwind/editor-surface/pm-decorations.ts +12 -0
  43. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +18 -30
  44. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +1 -1
  45. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +1 -1
  46. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +20 -11
  47. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +9 -4
  48. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +12 -7
  49. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +29 -10
  50. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +1 -1
  51. package/src/ui-tailwind/review-workspace/types.ts +3 -2
  52. package/src/ui-tailwind/review-workspace/use-page-markers.ts +11 -1
  53. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +2 -1
  54. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +2 -1
  55. package/src/ui-tailwind/tw-review-workspace.tsx +18 -2
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.83",
4
+ "version": "1.0.85",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -52,6 +52,9 @@ export function buildRefProjections(
52
52
  rejectAll: () => getRef().rejectAllChanges(),
53
53
  acceptGroup: (groupId) => getRef().acceptSuggestionGroup(groupId),
54
54
  rejectGroup: (groupId) => getRef().rejectSuggestionGroup(groupId),
55
+ getCommentThread: (changeId) => getRef().getCommentThreadForChange(changeId),
56
+ ensureCommentThread: (changeId) => getRef().ensureCommentThreadForChange(changeId),
57
+ addReply: (changeId, body) => getRef().addReplyToChange(changeId, body),
55
58
  scrollTo: (revisionId) => getRef().scrollToRevision(revisionId),
56
59
  get: () => getRef().getTrackedChanges(),
57
60
  getSuggestions: () => getRef().getSuggestionsSnapshot(),
@@ -592,6 +592,8 @@ export interface CommentSidebarThreadSnapshot {
592
592
  createdBy: string;
593
593
  warningCount: number;
594
594
  anchorLabel: string;
595
+ /** Present when this thread is the discussion attached to a tracked change. */
596
+ linkedRevisionId?: string;
595
597
  detachedReason?: "incomplete-markers" | "multi-paragraph" | "opaque-region" | "revision-overlap";
596
598
  actionabilityNote?: string;
597
599
  isActive: boolean;
@@ -630,6 +632,10 @@ export interface TrackedChangeEntrySnapshot {
630
632
  warningCount: number;
631
633
  canAccept: boolean;
632
634
  canReject: boolean;
635
+ /** Comment threads whose metadata links them to this revision. */
636
+ commentThreadIds?: string[];
637
+ /** Number of reply entries across linked comment threads. */
638
+ replyCount?: number;
633
639
  preserveOnlyReason?: string;
634
640
  excerpt?: string;
635
641
  detail?: string;
@@ -672,6 +678,10 @@ export interface SuggestionEntrySnapshot {
672
678
  preserveOnlyReason?: string;
673
679
  excerpt?: string;
674
680
  detail?: string;
681
+ /** Comment threads linked to any revision in this suggestion unit. */
682
+ commentThreadIds?: string[];
683
+ /** Number of reply entries across linked comment threads. */
684
+ replyCount?: number;
675
685
  /**
676
686
  * R3 — scope-card-overlay P2. When present, links this entry to a
677
687
  * `SuggestionGroup` via `SuggestionsSnapshot.groups[].groupId`.
@@ -1311,6 +1321,12 @@ export type SurfaceInlineSegment =
1311
1321
  instruction: string;
1312
1322
  refreshStatus: FieldRefreshStatus;
1313
1323
  label: string;
1324
+ /**
1325
+ * Current display text for fields whose visible value is computed by
1326
+ * layout context (PAGE / NUMPAGES / SECTIONPAGES). Consumers fall back
1327
+ * to `label` when absent.
1328
+ */
1329
+ displayText?: string;
1314
1330
  }
1315
1331
  | {
1316
1332
  /**
@@ -2512,6 +2528,16 @@ export interface AddScopeParams {
2512
2528
  storyTarget?: EditorStoryTarget;
2513
2529
  /** Optional display label for the scope card / rail. */
2514
2530
  label?: string;
2531
+ /**
2532
+ * Optional per-scope visibility. Controls chrome/query presentation only;
2533
+ * edit enforcement is controlled by `guardPolicy`.
2534
+ */
2535
+ visibility?: ScopeVisibility;
2536
+ /**
2537
+ * Optional edit-enforcement posture. Absent means advisory for
2538
+ * edit/suggest/comment scopes and read-only for `mode: "view"`.
2539
+ */
2540
+ guardPolicy?: WorkflowScopeGuardPolicy;
2515
2541
  }
2516
2542
 
2517
2543
  export interface AddScopeResult {
@@ -2591,6 +2617,19 @@ export interface ExportResult {
2591
2617
 
2592
2618
  export type WorkflowScopeMode = "edit" | "suggest" | "comment" | "view";
2593
2619
 
2620
+ /**
2621
+ * Explicit scope enforcement axis. Visibility is presentation-only.
2622
+ *
2623
+ * - `"none"`: advisory/metadata/chrome scope; direct editing is unaffected.
2624
+ * - `"insert-only"`: allowlist scope; when any active insert-only scope
2625
+ * exists, selection-driven edits outside insert-only scopes are blocked.
2626
+ * - `"read-only"`: deny edits inside this scope; outside content is unaffected.
2627
+ *
2628
+ * When absent, `mode: "view"` scopes default to `"read-only"` for
2629
+ * compatibility; all other modes default to `"none"`.
2630
+ */
2631
+ export type WorkflowScopeGuardPolicy = "none" | "insert-only" | "read-only";
2632
+
2594
2633
  /**
2595
2634
  * §C7 — Local chrome visibility mode. Never collab-replicated.
2596
2635
  * - `"all"`: show all scope rail entries + decorations (default).
@@ -2615,8 +2654,8 @@ export interface ScopeChromeVisibilityState {
2615
2654
  *
2616
2655
  * - `"visible"`: rail entry + card + inline decoration (current behavior).
2617
2656
  * - `"hidden"`: in the rail but muted; card opens on explicit click; no decoration.
2618
- * - `"invisible"`: never rendered; only queryable via API. Does NOT contribute
2619
- * to InteractionGuard unless `mode === "view"` (explicit host opt-in).
2657
+ * - `"invisible"`: never rendered; only queryable via API. Visibility never
2658
+ * contributes to InteractionGuard.
2620
2659
  */
2621
2660
  export type ScopeVisibility = "visible" | "hidden" | "invisible";
2622
2661
 
@@ -2639,6 +2678,12 @@ export interface WorkflowScope {
2639
2678
  domain?: "legal" | "commercial" | "finance" | "other";
2640
2679
  metadataRefs?: string[];
2641
2680
  metadata?: WorkflowScopeMetadataField[];
2681
+ /**
2682
+ * Explicit edit-enforcement posture. Absent means:
2683
+ * - `mode: "view"` -> `"read-only"` (legacy read-only behavior).
2684
+ * - all other modes -> `"none"` (advisory/chrome/metadata only).
2685
+ */
2686
+ guardPolicy?: WorkflowScopeGuardPolicy;
2642
2687
  /**
2643
2688
  * Schema 1.1 — override the overlay default for this scope.
2644
2689
  * `"inherit"` defers to the overlay; absent is equivalent to
@@ -4402,6 +4447,9 @@ export interface WordReviewEditorChangesFacet {
4402
4447
  rejectAll(): void;
4403
4448
  acceptGroup(groupId: string): void;
4404
4449
  rejectGroup(groupId: string): void;
4450
+ getCommentThread(changeId: string): CommentSidebarThreadSnapshot | null;
4451
+ ensureCommentThread(changeId: string): AddCommentResult | null;
4452
+ addReply(changeId: string, body: string): AddCommentReplyResult | null;
4405
4453
  scrollTo(revisionId: string): void;
4406
4454
  get(): TrackedChangesSnapshot;
4407
4455
  getSuggestions(): SuggestionsSnapshot;
@@ -4549,6 +4597,25 @@ export interface WordReviewEditorRef {
4549
4597
  commentId: string,
4550
4598
  body: string,
4551
4599
  ): AddCommentReplyResult | null;
4600
+ /**
4601
+ * Return the open/resolved comment thread linked to a tracked change, if
4602
+ * one exists. This is the read side for "reply to tracked change" UX.
4603
+ */
4604
+ getCommentThreadForChange(changeId: string): CommentSidebarThreadSnapshot | null;
4605
+ /**
4606
+ * Create or return the discussion thread attached to a tracked change.
4607
+ * Newly-created threads are anchored to the revision range and opened in
4608
+ * the comments rail by mounted UI callers.
4609
+ */
4610
+ ensureCommentThreadForChange(changeId: string): AddCommentResult | null;
4611
+ /**
4612
+ * Append a reply to a tracked change's linked discussion thread, creating
4613
+ * that thread first when needed.
4614
+ */
4615
+ addReplyToChange(
4616
+ changeId: string,
4617
+ body: string,
4618
+ ): AddCommentReplyResult | null;
4552
4619
  editCommentBody(commentId: string, body: string): void;
4553
4620
  deleteComment(commentId: string): void;
4554
4621
  /**
@@ -4571,8 +4638,9 @@ export interface WordReviewEditorRef {
4571
4638
  removeScope(scopeId: string): void;
4572
4639
  /**
4573
4640
  * §C8 — Convenience: adds a scope with `visibility: "invisible"` atomically.
4574
- * Mode defaults to `"comment"` invisible scopes carry no InteractionGuard
4575
- * constraint unless `mode: "view"` is passed explicitly.
4641
+ * Mode defaults to `"comment"`. Visibility is presentation-only; edit
4642
+ * gating is controlled by `guardPolicy` (`mode: "view"` still defaults to
4643
+ * read-only for compatibility unless `guardPolicy: "none"` is set).
4576
4644
  */
4577
4645
  addInvisibleScope(
4578
4646
  params: Omit<AddScopeParams, "mode"> & { mode?: WorkflowScopeMode },
@@ -4587,6 +4655,20 @@ export interface WordReviewEditorRef {
4587
4655
  * scopes or when the `visibility` field has never been set.
4588
4656
  */
4589
4657
  getScopeVisibility(scopeId: string): ScopeVisibility;
4658
+ /**
4659
+ * Set a scope's edit-enforcement posture. Collab-replicated through the
4660
+ * workflow overlay; visibility remains purely presentational.
4661
+ */
4662
+ setScopeGuardPolicy(
4663
+ scopeId: string,
4664
+ guardPolicy: WorkflowScopeGuardPolicy,
4665
+ ): void;
4666
+ /**
4667
+ * Get a scope's effective guard policy. Returns `"read-only"` for legacy
4668
+ * `mode: "view"` scopes with no explicit policy and `"none"` for unknown
4669
+ * scopes.
4670
+ */
4671
+ getScopeGuardPolicy(scopeId: string): WorkflowScopeGuardPolicy;
4590
4672
  /**
4591
4673
  * §C7 — Set the local chrome-visibility state. Local view-state only —
4592
4674
  * never collab-replicated. Controls whether the scope rail / decorations
@@ -53,12 +53,18 @@ export type RuntimeApiHandle = Pick<
53
53
  | "getCanonicalDocument"
54
54
  // Content search + selection (runtime.content + ai.bundle families)
55
55
  | "findAllText"
56
+ | "replaceText"
57
+ // Formatting mutations (runtime.formatting.apply)
58
+ | "applyFormattingOperation"
56
59
  // Review (runtime.review family)
57
60
  | "getReviewWorkSnapshot"
58
61
  | "getSuggestionsSnapshot"
59
62
  | "acceptChange"
60
63
  | "rejectChange"
61
64
  | "resolveComment"
65
+ | "getCommentThreadForChange"
66
+ | "ensureCommentThreadForChange"
67
+ | "addReplyToChange"
62
68
  // Workflow (runtime.workflow + ai.inspect families)
63
69
  | "queryScopes"
64
70
  | "getWorkflowMarkupSnapshot"
@@ -70,6 +76,8 @@ export type RuntimeApiHandle = Pick<
70
76
  // live seam. `getWorkflowMetadataSnapshot` is the read side the
71
77
  // metadata writer inspects before merging its entry.
72
78
  | "addScope"
79
+ | "setScopeGuardPolicy"
80
+ | "getScopeGuardPolicy"
73
81
  | "setWorkflowMetadataEntries"
74
82
  | "getWorkflowMetadataSnapshot"
75
83
  // W10 overlay-visibility policy (state-classes X1). Class-A canonical
@@ -145,16 +153,23 @@ export const RUNTIME_API_HANDLE_SHAPE_CHECK: Record<keyof RuntimeApiHandle, true
145
153
  getRenderSnapshot: true,
146
154
  getCanonicalDocument: true,
147
155
  findAllText: true,
156
+ replaceText: true,
157
+ applyFormattingOperation: true,
148
158
  getReviewWorkSnapshot: true,
149
159
  getSuggestionsSnapshot: true,
150
160
  acceptChange: true,
151
161
  rejectChange: true,
152
162
  resolveComment: true,
163
+ getCommentThreadForChange: true,
164
+ ensureCommentThreadForChange: true,
165
+ addReplyToChange: true,
153
166
  queryScopes: true,
154
167
  getWorkflowMarkupSnapshot: true,
155
168
  getInteractionGuardSnapshot: true,
156
169
  getWorkflowOverlay: true,
157
170
  addScope: true,
171
+ setScopeGuardPolicy: true,
172
+ getScopeGuardPolicy: true,
158
173
  setWorkflowMetadataEntries: true,
159
174
  getWorkflowMetadataSnapshot: true,
160
175
  getVisibilityPolicy: true,
@@ -12,7 +12,12 @@
12
12
 
13
13
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
14
14
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
15
- import type { CanonicalDocumentFragment } from "../../public-types.ts";
15
+ import type {
16
+ CanonicalDocumentFragment,
17
+ EditorAnchorProjection,
18
+ SurfaceBlockSnapshot,
19
+ SurfaceInlineSegment,
20
+ } from "../../public-types.ts";
16
21
  import { emitUxResponse } from "../_ux-response.ts";
17
22
  import { createScopeCompilerService } from "../../../runtime/scopes/index.ts";
18
23
 
@@ -179,6 +184,18 @@ export function createContentFamily(runtime: RuntimeApiHandle) {
179
184
  // Layer-08 compiler-service facade. Same pipeline as
180
185
  // ai.applyReplacementScope; the host/UI path omits actionId (not
181
186
  // an agent action) and tags origin:"ui" + actorId:"user".
187
+ const tableCellResult = replaceTableCellText(runtime, input);
188
+ if (tableCellResult) {
189
+ emitUxResponse(runtime, {
190
+ apiFn: replaceTextMetadata.name,
191
+ intent: replaceTextMetadata.uxIntent.expectedDelta ?? "",
192
+ mockOrLive: "live",
193
+ uiVisible: true,
194
+ expectedDelta: replaceTextMetadata.uxIntent.expectedDelta,
195
+ });
196
+ return tableCellResult;
197
+ }
198
+
182
199
  const result = compiler.applyReplacement({
183
200
  targetScopeId: input.scopeId,
184
201
  operation: "replace",
@@ -234,3 +251,133 @@ export function createContentFamily(runtime: RuntimeApiHandle) {
234
251
  },
235
252
  };
236
253
  }
254
+
255
+ function replaceTableCellText(
256
+ runtime: RuntimeApiHandle,
257
+ input: ReplaceTextInput,
258
+ ): ReplaceTextResult | null {
259
+ const parsed = parseTableCellScopeId(input.scopeId);
260
+ if (!parsed) {
261
+ return null;
262
+ }
263
+
264
+ const snapshot = runtime.getRenderSnapshot();
265
+ if (!snapshot.isReady || snapshot.readOnly || snapshot.fatalError) {
266
+ return { applied: false, reason: "document-not-editable" };
267
+ }
268
+
269
+ const document = runtime.getCanonicalDocument();
270
+ const rootChild = document.content.children[parsed.blockIndex];
271
+ if (!rootChild || rootChild.type !== "table") {
272
+ return { applied: false, reason: "table-cell-scope-not-resolvable" };
273
+ }
274
+
275
+ const tableOrdinal = document.content.children
276
+ .slice(0, parsed.blockIndex)
277
+ .filter((block) => block.type === "table").length;
278
+ const tableBlock =
279
+ snapshot.surface?.blocks.find(
280
+ (block): block is Extract<SurfaceBlockSnapshot, { kind: "table" }> =>
281
+ block.kind === "table" && block.blockId === `table-${tableOrdinal}`,
282
+ ) ??
283
+ (snapshot.surface?.blocks[parsed.blockIndex]?.kind === "table"
284
+ ? snapshot.surface.blocks[parsed.blockIndex]
285
+ : null);
286
+ if (!tableBlock || tableBlock.kind !== "table") {
287
+ return { applied: false, reason: "table-cell-surface-not-realized" };
288
+ }
289
+
290
+ const cell = tableBlock.rows[parsed.rowIndex]?.cells[parsed.cellIndex];
291
+ if (!cell) {
292
+ return { applied: false, reason: "table-cell-scope-not-resolvable" };
293
+ }
294
+
295
+ const range = resolveTableCellTextRange(cell.content);
296
+ if (range.status === "empty") {
297
+ return { applied: false, reason: "table-cell-empty-text" };
298
+ }
299
+ if (range.status === "ambiguous") {
300
+ return { applied: false, reason: "table-cell-text-scope-ambiguous" };
301
+ }
302
+ const { from, to } = range;
303
+
304
+ const anchor: EditorAnchorProjection = {
305
+ kind: "range",
306
+ from,
307
+ to,
308
+ assoc: { start: -1, end: 1 },
309
+ };
310
+ const beforeRevisionToken = snapshot.revisionToken;
311
+ runtime.replaceText(input.replacement, anchor);
312
+ const afterRevisionToken = runtime.getRenderSnapshot().revisionToken;
313
+ if (afterRevisionToken === beforeRevisionToken) {
314
+ return { applied: false, reason: "replace-text-not-applied" };
315
+ }
316
+ return { applied: true };
317
+ }
318
+
319
+ function parseTableCellScopeId(
320
+ scopeId: string,
321
+ ): { blockIndex: number; rowIndex: number; cellIndex: number } | null {
322
+ const match = /^cell:(\d+):(\d+):(\d+)$/u.exec(scopeId);
323
+ if (!match) return null;
324
+ return {
325
+ blockIndex: Number(match[1]),
326
+ rowIndex: Number(match[2]),
327
+ cellIndex: Number(match[3]),
328
+ };
329
+ }
330
+
331
+ type TableCellTextRangeResult =
332
+ | { status: "ok"; from: number; to: number }
333
+ | { status: "empty" }
334
+ | { status: "ambiguous" };
335
+
336
+ function resolveTableCellTextRange(
337
+ blocks: readonly SurfaceBlockSnapshot[],
338
+ ): TableCellTextRangeResult {
339
+ const paragraphRanges = collectTableCellParagraphTextRanges(blocks);
340
+ if (paragraphRanges.length === 0) {
341
+ return { status: "empty" };
342
+ }
343
+ if (paragraphRanges.length > 1) {
344
+ return { status: "ambiguous" };
345
+ }
346
+ const [range] = paragraphRanges;
347
+ return range && range.from < range.to
348
+ ? { status: "ok", from: range.from, to: range.to }
349
+ : { status: "empty" };
350
+ }
351
+
352
+ function collectTableCellParagraphTextRanges(
353
+ blocks: readonly SurfaceBlockSnapshot[],
354
+ output: Array<{ from: number; to: number }> = [],
355
+ ): Array<{ from: number; to: number }> {
356
+ for (const block of blocks) {
357
+ if (block.kind === "paragraph") {
358
+ const textSegments = block.segments.filter(
359
+ (segment): segment is Extract<SurfaceInlineSegment, { kind: "text" }> =>
360
+ segment.kind === "text" && segment.from < segment.to,
361
+ );
362
+ if (textSegments.length > 0) {
363
+ output.push({
364
+ from: Math.min(...textSegments.map((segment) => segment.from)),
365
+ to: Math.max(...textSegments.map((segment) => segment.to)),
366
+ });
367
+ }
368
+ continue;
369
+ }
370
+ if (block.kind === "table") {
371
+ for (const row of block.rows) {
372
+ for (const cell of row.cells) {
373
+ collectTableCellParagraphTextRanges(cell.content, output);
374
+ }
375
+ }
376
+ continue;
377
+ }
378
+ if (block.kind === "sdt_block") {
379
+ collectTableCellParagraphTextRanges(block.children, output);
380
+ }
381
+ }
382
+ return output;
383
+ }
@@ -13,12 +13,14 @@
13
13
 
14
14
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
15
15
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
16
+ import { emitUxResponse } from "../_ux-response.ts";
16
17
  import {
17
18
  resolveEffectiveFormatting,
18
19
  createFormattingContext,
19
20
  type EffectiveFormatting,
20
21
  type RunProvenance,
21
22
  } from "../../../runtime/formatting/index.ts";
23
+ import type { FormattingOperation } from "../../../core/commands/formatting-commands.ts";
22
24
  import {
23
25
  findParagraphByBlockId,
24
26
  resolveDirectRunFormattingAtSegment,
@@ -132,8 +134,47 @@ export const resolveRunWithProvenanceMetadata: ApiV3FnMetadata = {
132
134
  "§Runtime API § runtime.formatting.resolveRunWithProvenance. Agent-facing provenance view — `source ∈ {direct, characterStyle, style, docDefaults}` plus `sourceId` for style tiers. Substrate for ai.explainFormatting (L09).",
133
135
  };
134
136
 
137
+ export const applyMetadata: ApiV3FnMetadata = {
138
+ name: "runtime.formatting.apply",
139
+ status: "live",
140
+ sourceLayer: "runtime-core",
141
+ liveEvidence: {
142
+ runnerTest: "test/api/v3/runtime/formatting-adapter.test.ts",
143
+ commit: "refactor-03-tracked-changes-v1-formatting-apply-2026-04-23",
144
+ },
145
+ stateClass: "A-canonical",
146
+ persistsTo: "canonical",
147
+ broadcastsVia: "crdt",
148
+ uxIntent: {
149
+ uiVisible: true,
150
+ expectsUxResponse: "inline-change",
151
+ expectedDelta: "formatting changes in the active selection",
152
+ },
153
+ agentMetadata: {
154
+ readOrMutate: "mutate",
155
+ boundedScope: "selection",
156
+ auditCategory: "formatting-write",
157
+ },
158
+ rwdReference:
159
+ "§Runtime API § runtime.formatting.apply. Live adapter over DocumentRuntime.applyFormattingOperation(); supports direct formatting/paragraph alignment/indentation and authors bounded property-change suggestions when the effective mode is suggesting. Style suggestions are intentionally not accepted by this operation shape.",
160
+ };
161
+
135
162
  export function createFormattingFamily(runtime: RuntimeApiHandle) {
136
163
  return {
164
+ apply(operation: FormattingOperation): void {
165
+ // @endStateApi — live. Delegates to the runtime-owned mutation seam
166
+ // so direct edits, workflow blocking, and suggesting-mode property-
167
+ // change authorship all share one implementation.
168
+ runtime.applyFormattingOperation(operation);
169
+ emitUxResponse(runtime, {
170
+ apiFn: applyMetadata.name,
171
+ intent: applyMetadata.uxIntent.expectedDelta ?? "",
172
+ mockOrLive: "live",
173
+ uiVisible: true,
174
+ expectedDelta: applyMetadata.uxIntent.expectedDelta,
175
+ });
176
+ },
177
+
137
178
  getEffective(
138
179
  nodeRef: FormattingNodeRef,
139
180
  opts?: FormattingGetEffectiveOpts,
@@ -8,6 +8,8 @@
8
8
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
9
9
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
10
10
  import type {
11
+ AddCommentReplyResult,
12
+ AddCommentResult,
11
13
  CommentSidebarThreadSnapshot,
12
14
  SuggestionsSnapshot,
13
15
  TrackedChangeEntrySnapshot,
@@ -120,6 +122,56 @@ export const resolveCommentMetadata: ApiV3FnMetadata = {
120
122
  rwdReference: "§Runtime API § runtime.review.resolveComment",
121
123
  };
122
124
 
125
+ export const getCommentThreadForChangeMetadata: ApiV3FnMetadata = {
126
+ name: "runtime.review.getCommentThreadForChange",
127
+ status: "live",
128
+ sourceLayer: "workflow-review",
129
+ liveEvidence: {
130
+ runnerTest: "test/api/v3/create-accepts-handle.test.ts",
131
+ commit: "refactor-03-redline-revision-thread-link",
132
+ },
133
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
134
+ agentMetadata: { readOrMutate: "read", boundedScope: "scope", auditCategory: "review-read" },
135
+ stateClass: "A-canonical",
136
+ persistsTo: "canonical",
137
+ rwdReference:
138
+ "§Runtime API § runtime.review.getCommentThreadForChange. Live adapter over runtime linked revision comment threads.",
139
+ };
140
+
141
+ export const ensureCommentThreadForChangeMetadata: ApiV3FnMetadata = {
142
+ name: "runtime.review.ensureCommentThreadForChange",
143
+ status: "live",
144
+ sourceLayer: "workflow-review",
145
+ liveEvidence: {
146
+ runnerTest: "test/api/v3/create-accepts-handle.test.ts",
147
+ commit: "refactor-03-redline-revision-thread-link",
148
+ },
149
+ uxIntent: { uiVisible: true, expectsUxResponse: "inline-change", expectedDelta: "comment thread opens for tracked change" },
150
+ agentMetadata: { readOrMutate: "mutate", boundedScope: "scope", auditCategory: "comment-add" },
151
+ stateClass: "A-canonical",
152
+ persistsTo: "canonical",
153
+ broadcastsVia: "crdt",
154
+ rwdReference:
155
+ "§Runtime API § runtime.review.ensureCommentThreadForChange. Creates or returns the comment thread attached to a tracked change.",
156
+ };
157
+
158
+ export const addReplyToChangeMetadata: ApiV3FnMetadata = {
159
+ name: "runtime.review.addReplyToChange",
160
+ status: "live",
161
+ sourceLayer: "workflow-review",
162
+ liveEvidence: {
163
+ runnerTest: "test/api/v3/create-accepts-handle.test.ts",
164
+ commit: "refactor-03-redline-revision-thread-link",
165
+ },
166
+ uxIntent: { uiVisible: true, expectsUxResponse: "inline-change", expectedDelta: "reply appears on tracked-change thread" },
167
+ agentMetadata: { readOrMutate: "mutate", boundedScope: "scope", auditCategory: "comment-reply" },
168
+ stateClass: "A-canonical",
169
+ persistsTo: "canonical",
170
+ broadcastsVia: "crdt",
171
+ rwdReference:
172
+ "§Runtime API § runtime.review.addReplyToChange. Appends a reply to the comment thread linked to a tracked change.",
173
+ };
174
+
123
175
  export function createReviewFamily(runtime: RuntimeApiHandle) {
124
176
  return {
125
177
  getComments(): readonly CommentSidebarThreadSnapshot[] {
@@ -140,6 +192,52 @@ export function createReviewFamily(runtime: RuntimeApiHandle) {
140
192
  return runtime.getSuggestionsSnapshot();
141
193
  },
142
194
 
195
+ getCommentThreadForChange(changeId: string): CommentSidebarThreadSnapshot | null {
196
+ // @endStateApi — live. Reads the comment thread linked to a tracked
197
+ // change from the canonical comment projection.
198
+ return runtime.getCommentThreadForChange(changeId);
199
+ },
200
+
201
+ ensureCommentThreadForChange(changeId: string): AddCommentResult | null {
202
+ // @endStateApi — live. Ensures a canonical comment thread exists for
203
+ // a tracked change and emits an inline UX response when created/found.
204
+ const result = runtime.ensureCommentThreadForChange(changeId);
205
+ if (result) {
206
+ emitUxResponse(runtime, {
207
+ apiFn: ensureCommentThreadForChangeMetadata.name,
208
+ intent: ensureCommentThreadForChangeMetadata.uxIntent.expectedDelta ?? "",
209
+ mockOrLive: "live",
210
+ uiVisible: true,
211
+ expectedDelta: ensureCommentThreadForChangeMetadata.uxIntent.expectedDelta,
212
+ actualDelta: {
213
+ kind: "inline-change",
214
+ payload: { changeId, commentId: result.commentId },
215
+ },
216
+ });
217
+ }
218
+ return result;
219
+ },
220
+
221
+ addReplyToChange(changeId: string, body: string): AddCommentReplyResult | null {
222
+ // @endStateApi — live. Appends a canonical comment reply to the
223
+ // thread linked to a tracked change.
224
+ const result = runtime.addReplyToChange(changeId, body);
225
+ if (result) {
226
+ emitUxResponse(runtime, {
227
+ apiFn: addReplyToChangeMetadata.name,
228
+ intent: addReplyToChangeMetadata.uxIntent.expectedDelta ?? "",
229
+ mockOrLive: "live",
230
+ uiVisible: true,
231
+ expectedDelta: addReplyToChangeMetadata.uxIntent.expectedDelta,
232
+ actualDelta: {
233
+ kind: "inline-change",
234
+ payload: { changeId, commentId: result.commentId, entryId: result.entryId },
235
+ },
236
+ });
237
+ }
238
+ return result;
239
+ },
240
+
143
241
  acceptChange(changeId: string): void {
144
242
  // @endStateApi — live. Delegates.
145
243
  runtime.acceptChange(changeId);