@beyondwork/docx-react-component 1.0.76 → 1.0.77

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.
@@ -1,16 +1,35 @@
1
1
  /**
2
- * Slice 3 reference resolution.
2
+ * Reference resolution + position queries.
3
3
  *
4
- * Takes an agent-supplied `ScopeReference` hint + a live document and
5
- * returns either a resolved `ScopeHandle`, a list of candidate handles
6
- * (ambiguous), a detached placeholder (scopeId was once valid), or a
7
- * `not-found`.
4
+ * Two distinct surfaces live in this module now, matching the two
5
+ * distinct semantics (KI-P9 fix):
8
6
  *
9
- * Deterministic hint kinds `scope-id`, `semantic-path`, `offset`,
10
- * `range` all resolve live today. Natural-language hints return a
11
- * `partial` result with `confidence: "low"`; a richer NL matcher lives
12
- * in a later slice (the AI API will keep the function at status
13
- * `partial` until then).
7
+ * 1. `resolveReference(ref)`**identity lookup**. Takes a durable
8
+ * `ScopeReference` (`scope-id`, `semantic-path`, or
9
+ * `natural-language` hint) and returns the scope it names. Safe
10
+ * across mutations: `scope-id` survives text replaces; missing
11
+ * references return typed `not-found` / `detached`. Callers that
12
+ * hold a `ScopeReference` across a mutation window should only ever
13
+ * be holding one of these durable kinds.
14
+ *
15
+ * 2. `queryScopeAtPosition(at)` / `queryScopeInRange(from, to)` —
16
+ * **one-shot positional queries**. Return a `ScopeHandle | null`
17
+ * against the *current* document state. The caller's next use of
18
+ * the returned handle MUST be through its `scopeId` — the position
19
+ * is consumed by the query and never round-trips as a reference.
20
+ * This is the click-to-scope / hit-test / selection-to-scope
21
+ * surface. Positions that lived at time T1 are meaningless at time
22
+ * T2 after any intervening mutation, so these functions refuse to
23
+ * be part of the stored-reference vocabulary.
24
+ *
25
+ * Before 2026-04-24, `ScopeReference` had `offset` and `range` variants
26
+ * that flowed through `resolveReference` with the same
27
+ * `{status:"resolved", handle, confidence}` shape as identity lookups.
28
+ * That conflation is the KI-P9 trap — documented in
29
+ * `docs/testing/scopes.md §6c` and `KNOWN-ISSUES.md` KI-P9. Removed.
30
+ *
31
+ * Natural-language hints continue to resolve through `resolveReference`
32
+ * with `confidence: "low"`; richer NL matching is a later slice.
14
33
  */
15
34
 
16
35
  import type { CanonicalDocument } from "../../model/canonical-document.ts";
@@ -23,11 +42,28 @@ import { buildScopePositionMap, type ScopePositionRange } from "./position-map.t
23
42
  import { resolveScopeRange, scopeSpecificity } from "./scope-range.ts";
24
43
  import type { ScopeHandle } from "./semantic-scope-types.ts";
25
44
 
45
+ /**
46
+ * Durable references to a scope. Each kind is safe to cache / store /
47
+ * round-trip through a tool-call boundary and re-resolve after
48
+ * arbitrary mutations:
49
+ *
50
+ * - `scope-id` — identity primitive. Preserved through text edits;
51
+ * returns `not-found` / `detached` cleanly on delete.
52
+ * - `semantic-path` — structural path (`body/paragraph/5`). Stable
53
+ * when block structure doesn't change; returns `not-found` when
54
+ * the path no longer resolves. Fragile on KI-001-class fixtures
55
+ * where a neighbour block drop shifts all later indices by 1.
56
+ * - `natural-language` — substring heuristic. Always
57
+ * `confidence: "low"`; callers must surface that to humans.
58
+ *
59
+ * Positional references (`offset`, `range`) are **not** part of this
60
+ * union — see `queryScopeAtPosition` / `queryScopeInRange` for those.
61
+ * They are one-shot queries that return a `ScopeHandle | null`; the
62
+ * position never becomes a stored reference. Cf. KI-P9.
63
+ */
26
64
  export type ScopeReference =
27
65
  | { readonly kind: "scope-id"; readonly value: string }
28
66
  | { readonly kind: "semantic-path"; readonly path: readonly string[] }
29
- | { readonly kind: "offset"; readonly at: number }
30
- | { readonly kind: "range"; readonly from: number; readonly to: number }
31
67
  | { readonly kind: "natural-language"; readonly hint: string };
32
68
 
33
69
  export type ResolveReferenceResult =
@@ -161,50 +197,74 @@ function innermostContaining(
161
197
  return best?.entry ?? null;
162
198
  }
163
199
 
164
- function resolveByOffset(
200
+ /**
201
+ * Inputs for the one-shot positional query functions. Narrower than
202
+ * `ResolveReferenceInputs` because the query functions never need NL
203
+ * overlay labels — they're pure structural lookups.
204
+ */
205
+ export interface QueryScopePositionInputs {
206
+ readonly document:
207
+ | Pick<CanonicalDocument, "content" | "docId" | "review">
208
+ | CanonicalDocumentEnvelope;
209
+ readonly overlay?: WorkflowOverlay | null;
210
+ readonly scopes?: readonly EnumeratedScope[];
211
+ }
212
+
213
+ /**
214
+ * Return the innermost scope whose range contains `at` in the current
215
+ * document state, or `null` if no scope contains the position.
216
+ *
217
+ * **One-shot query, not a stored reference.** The returned handle is
218
+ * the durable object — its `scopeId` may be stored and re-resolved via
219
+ * `resolveReference({kind:"scope-id", value: handle.scopeId})`. The
220
+ * input position must NOT be stored; it is meaningful only against the
221
+ * document state it was queried against. See KI-P9 for the trap this
222
+ * separation closes.
223
+ *
224
+ * Precision: marker-backed handles (workflow / comment / revision
225
+ * scopes anchored by inline `scope_marker_*` nodes) are returned with
226
+ * full confidence; derived handles (paragraph / heading / list-item /
227
+ * field / table etc. enumerated from the canonical tree) are just as
228
+ * authoritative — the confidence distinction that previously lived on
229
+ * the `{status:"resolved", confidence}` return went away with
230
+ * `resolveReference`'s offset/range cases. Callers that need to know
231
+ * whether the hit is marker-backed can read `handle.provenance`.
232
+ */
233
+ export function queryScopeAtPosition(
165
234
  at: number,
166
- scopes: readonly EnumeratedScope[],
167
- positionMap: ReturnType<typeof buildScopePositionMap>,
168
- ): ResolveReferenceResult {
235
+ inputs: QueryScopePositionInputs,
236
+ ): ScopeHandle | null {
237
+ const scopes = scopesFor({ ...inputs, overlay: inputs.overlay ?? null });
238
+ const positionMap = buildScopePositionMap(inputs.document);
169
239
  const hit = innermostContaining(
170
240
  scopes,
171
241
  positionMap,
172
242
  (range) => range.from <= at && at <= range.to,
173
243
  );
174
- if (!hit) {
175
- return { status: "not-found", reason: `no scope contains offset ${at}` };
176
- }
177
- return {
178
- status: "resolved",
179
- handle: hit.handle,
180
- confidence: hit.handle.provenance === "marker-backed" ? "high" : "medium",
181
- };
244
+ return hit?.handle ?? null;
182
245
  }
183
246
 
184
- function resolveByRange(
247
+ /**
248
+ * Return the innermost scope that fully contains the range `[from, to]`
249
+ * in the current document state, or `null` if no single scope contains
250
+ * the whole range. Same one-shot-query semantics as
251
+ * `queryScopeAtPosition`.
252
+ */
253
+ export function queryScopeInRange(
185
254
  from: number,
186
255
  to: number,
187
- scopes: readonly EnumeratedScope[],
188
- positionMap: ReturnType<typeof buildScopePositionMap>,
189
- ): ResolveReferenceResult {
256
+ inputs: QueryScopePositionInputs,
257
+ ): ScopeHandle | null {
190
258
  const low = Math.min(from, to);
191
259
  const high = Math.max(from, to);
260
+ const scopes = scopesFor({ ...inputs, overlay: inputs.overlay ?? null });
261
+ const positionMap = buildScopePositionMap(inputs.document);
192
262
  const hit = innermostContaining(
193
263
  scopes,
194
264
  positionMap,
195
265
  (range) => range.from <= low && high <= range.to,
196
266
  );
197
- if (!hit) {
198
- return {
199
- status: "not-found",
200
- reason: `no scope fully contains range [${low}, ${high}]`,
201
- };
202
- }
203
- return {
204
- status: "resolved",
205
- handle: hit.handle,
206
- confidence: hit.handle.provenance === "marker-backed" ? "high" : "medium",
207
- };
267
+ return hit?.handle ?? null;
208
268
  }
209
269
 
210
270
  /**
@@ -329,10 +389,6 @@ export function resolveReference(
329
389
  return resolveByScopeId(reference.value, scopes, inputs.overlay, positionMap);
330
390
  case "semantic-path":
331
391
  return resolveBySemanticPath(reference.path, scopes);
332
- case "offset":
333
- return resolveByOffset(reference.at, scopes, positionMap);
334
- case "range":
335
- return resolveByRange(reference.from, reference.to, scopes, positionMap);
336
392
  case "natural-language":
337
393
  return resolveByNaturalLanguage(
338
394
  reference.hint,
@@ -137,6 +137,32 @@ export interface LoadDocxEditorSessionOptions {
137
137
  * inspect every marker.
138
138
  */
139
139
  stripCosmeticMarkers?: boolean;
140
+ /**
141
+ * Phase 2 bookmark-strip allowlist. When `stripCosmeticMarkers` is
142
+ * `true` (the default), the parser's reference scan retains
143
+ * bookmarks whose name is referenced by a `<w:hyperlink w:anchor>`
144
+ * or `<w:instrText>` (REF / PAGEREF / NOTEREF / TOC) AND any name
145
+ * listed here. Use this when the host depends on a stable
146
+ * host-authored bookmark name (e.g. `placeholder_party_name`,
147
+ * `signature_block_2`) that the parser's automatic scan cannot
148
+ * infer is load-bearing.
149
+ *
150
+ * Default: `[]` (only the automatic scan retains names).
151
+ *
152
+ * Always-retained, regardless of this list:
153
+ * - `_Toc*` (when any TOC field exists in the document)
154
+ * - `_Ref*` and any other name explicitly cited by a hyperlink
155
+ * anchor or REF/PAGEREF/NOTEREF instruction
156
+ * - `bw:scope:*` (workflow scope markers — converted to
157
+ * first-class scope markers by the parser before strip runs)
158
+ * - everything (defensive blanket-retain) when the document
159
+ * contains a `<w:dataBinding>` whose xpath could reference
160
+ * bookmarks via paths the scanner cannot statically analyze
161
+ *
162
+ * See `services/debug/docs/phase-2-bookmark-strip-audit-2026-04-24.md`
163
+ * for the corpus categorization that informed this contract.
164
+ */
165
+ retainedBookmarkNames?: ReadonlyArray<string>;
140
166
  }
141
167
 
142
168
  /**
@@ -540,7 +540,12 @@ export async function loadDocxSessionAsync(
540
540
  mediaParts,
541
541
  mainDocumentPath,
542
542
  chartPartLookup,
543
- { stripCosmeticMarkers: options.stripCosmeticMarkers !== false },
543
+ {
544
+ stripCosmeticMarkers: options.stripCosmeticMarkers !== false,
545
+ ...(options.retainedBookmarkNames !== undefined
546
+ ? { retainedBookmarkNames: options.retainedBookmarkNames }
547
+ : {}),
548
+ },
544
549
  );
545
550
  } finally {
546
551
  if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
@@ -1313,7 +1318,12 @@ export function loadDocxSessionSync(
1313
1318
  mediaParts,
1314
1319
  mainDocumentPath,
1315
1320
  chartPartLookup,
1316
- { stripCosmeticMarkers: options.stripCosmeticMarkers !== false },
1321
+ {
1322
+ stripCosmeticMarkers: options.stripCosmeticMarkers !== false,
1323
+ ...(options.retainedBookmarkNames !== undefined
1324
+ ? { retainedBookmarkNames: options.retainedBookmarkNames }
1325
+ : {}),
1326
+ },
1317
1327
  );
1318
1328
  } finally {
1319
1329
  if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
@@ -12,6 +12,9 @@ export type PerfProbeKind =
12
12
  | "snapshot.navigation"
13
13
  | "pm.rebuild"
14
14
  | "pm.decorations"
15
+ | "pm.decorations.comments"
16
+ | "pm.decorations.revisions"
17
+ | "pm.decorations.workflow"
15
18
  | "pm.mount"
16
19
  | "shell.render"
17
20
  | "workspace.chrome"
@@ -20,9 +20,26 @@ import type {
20
20
  WorkflowScope,
21
21
  } from "../../api/public-types";
22
22
  import { MAIN_STORY_TARGET, storyTargetsEqual } from "../../api/public-types.ts";
23
+ import {
24
+ incrementInvalidationCounter,
25
+ recordPerfSample,
26
+ } from "./perf-probe";
23
27
  import type { PositionMap } from "./pm-position-map";
24
28
  import type { Node as PMNode } from "prosemirror-model";
25
29
 
30
+ /**
31
+ * Cheap wall-clock delta helper for sub-section probes. `performance.now`
32
+ * is always defined in the browser surface where this runs; the guard
33
+ * keeps headless Node contexts (tests) from throwing when the global
34
+ * isn't polyfilled.
35
+ */
36
+ function nowMs(): number {
37
+ return typeof performance !== "undefined" &&
38
+ typeof performance.now === "function"
39
+ ? performance.now()
40
+ : Date.now();
41
+ }
42
+
26
43
  type RailDecorationSpec = {
27
44
  railKind: "scope" | "candidate" | "blocked";
28
45
  className: string;
@@ -394,6 +411,8 @@ export function buildDecorations(
394
411
  const lockedPmRanges = collectLockedPmRanges(workflowLockedZones, activeStory, positionMap);
395
412
 
396
413
  // Walk comment threads and create inline decorations
414
+ const commentsStartMs = nowMs();
415
+ let commentCount = 0;
397
416
  if (commentModel) {
398
417
  for (const thread of commentModel.threads) {
399
418
  const cls = getCommentHighlightClass(
@@ -413,11 +432,18 @@ export function buildDecorations(
413
432
  "data-comment-id": thread.commentId,
414
433
  }),
415
434
  );
435
+ commentCount += 1;
416
436
  }
417
437
  }
418
438
  }
439
+ recordPerfSample("pm.decorations.comments", nowMs() - commentsStartMs);
440
+ if (commentCount > 0) {
441
+ incrementInvalidationCounter("pm.decorations.comments.count", commentCount);
442
+ }
419
443
 
420
444
  // Walk revision entries and create inline decorations.
445
+ const revisionsStartMs = nowMs();
446
+ let revisionCount = 0;
421
447
  // Deletion hiding in clean mode ALWAYS applies, even when showTrackedChanges is off.
422
448
  // Visual styling (underlines, colors) only applies when showTrackedChanges is on.
423
449
  if (revisionModel) {
@@ -442,6 +468,7 @@ export function buildDecorations(
442
468
  "data-revision-id": rev.revisionId,
443
469
  }),
444
470
  );
471
+ revisionCount += 1;
445
472
  }
446
473
  continue;
447
474
  }
@@ -477,6 +504,7 @@ export function buildDecorations(
477
504
  return el;
478
505
  }, { side: 1, key: `${rev.revisionId}-close` }),
479
506
  );
507
+ revisionCount += 1;
480
508
  } else if (rev.kind === "deletion") {
481
509
  decorations.push(
482
510
  Decoration.inline(pmFrom, pmTo, {
@@ -484,6 +512,7 @@ export function buildDecorations(
484
512
  "data-revision-id": rev.revisionId,
485
513
  }),
486
514
  );
515
+ revisionCount += 1;
487
516
  }
488
517
  continue;
489
518
  }
@@ -511,9 +540,16 @@ export function buildDecorations(
511
540
  "data-revision-id": rev.revisionId,
512
541
  }),
513
542
  );
543
+ revisionCount += 1;
514
544
  }
515
545
  }
546
+ recordPerfSample("pm.decorations.revisions", nowMs() - revisionsStartMs);
547
+ if (revisionCount > 0) {
548
+ incrementInvalidationCounter("pm.decorations.revisions.count", revisionCount);
549
+ }
516
550
 
551
+ const workflowStartMs = nowMs();
552
+ const workflowDecorationsBefore = decorations.length;
517
553
  if (effectiveWorkflowScopes.length > 0) {
518
554
  for (const scope of effectiveWorkflowScopes) {
519
555
  const scopeStoryTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
@@ -655,6 +691,14 @@ export function buildDecorations(
655
691
  }, railRangeCache);
656
692
  }
657
693
  }
694
+ recordPerfSample("pm.decorations.workflow", nowMs() - workflowStartMs);
695
+ const workflowDecorationCount = decorations.length - workflowDecorationsBefore;
696
+ if (workflowDecorationCount > 0) {
697
+ incrementInvalidationCounter(
698
+ "pm.decorations.workflow.count",
699
+ workflowDecorationCount,
700
+ );
701
+ }
658
702
 
659
703
  return DecorationSet.create(doc, decorations);
660
704
  }
@@ -161,6 +161,14 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
161
161
  * `preserve-position-ordering.test.ts`.
162
162
  */
163
163
  suppressionRef: EchoSuppressionRef;
164
+ /**
165
+ * When `true`, wrap the state swap in `capturePosition` /
166
+ * `restorePosition` so the scroll anchor block stays at the same
167
+ * viewport-Y across the replacement. Shipped **disabled by default**
168
+ * after the 2026-04-24 jump-to-top regression — re-enable under a
169
+ * diagnosed-safe codepath only.
170
+ */
171
+ preserveScrollAnchor?: boolean;
164
172
  /**
165
173
  * Microtask scheduler. Defaults to the global `queueMicrotask`.
166
174
  * Tests override it to capture the release callback.
@@ -169,17 +177,17 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
169
177
  }
170
178
 
171
179
  /**
172
- * Replace the view's state with `newState` while preserving the user's
173
- * scroll position AND suppressing the selection-sync echo that would
174
- * otherwise fire when PM dispatches its internal selection-change
175
- * notifications during the swap.
180
+ * Replace the view's state with `newState`, suppressing the
181
+ * selection-sync echo during the swap.
176
182
  *
177
- * Ordering invariant (regression-guarded):
183
+ * Ordering invariant (regression-guarded by
184
+ * `preserve-position-ordering.test.ts`):
178
185
  *
179
- * 1. capture scroll anchor
186
+ * 1. (optional) capture scroll anchor — gated on
187
+ * `preserveScrollAnchor: true` and a live `geometryFacet`
180
188
  * 2. suppressionRef.current = true
181
189
  * 3. view.updateState(newState) ← PM may fire selection events here
182
- * 4. restore scroll anchor
190
+ * 4. (optional) restore scroll anchor
183
191
  * 5. queueMicrotask(() => suppressionRef.current = false)
184
192
  *
185
193
  * The microtask release guarantees the flag is still `true` for any
@@ -191,15 +199,26 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
191
199
  * `requestAnimationFrame` is deliberate — a later scheduler would
192
200
  * leave the flag stuck true across a macrotask boundary, causing
193
201
  * legitimate post-swap selection changes to be swallowed.
202
+ *
203
+ * **Scroll preservation is opt-in.** Shipped disabled by default
204
+ * after the 2026-04-24 jump-to-top regression report — enabling it
205
+ * requires evidence that the anchor math holds under the
206
+ * rebuild-effect's exact timing (PM DOM mid-mutation, observer-driven
207
+ * scrollTop resets, etc.). The capture/restore helpers are still
208
+ * exported + unit-tested for the eventual re-enable.
194
209
  */
195
210
  export function replaceStatePreservingPosition(
196
211
  options: ReplaceStateOptions,
197
212
  newState: import("prosemirror-state").EditorState,
198
213
  ): void {
199
- const preserved = capturePosition(options);
214
+ const preserved = options.preserveScrollAnchor
215
+ ? capturePosition(options)
216
+ : null;
200
217
  options.suppressionRef.current = true;
201
218
  options.view.updateState(newState);
202
- restorePosition(preserved, options);
219
+ if (preserved) {
220
+ restorePosition(preserved, options);
221
+ }
203
222
  const release = () => {
204
223
  options.suppressionRef.current = false;
205
224
  };