@agent-native/core-corpus 0.217.0 → 0.219.0

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 (81) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/hubspot/SKILL.md +5 -3
  3. package/corpus/templates/analytics/CHANGELOG.md +2 -0
  4. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +38 -167
  5. package/corpus/templates/analytics/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  6. package/corpus/templates/assets/CHANGELOG.md +6 -0
  7. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +51 -117
  8. package/corpus/templates/assets/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  9. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +34 -101
  10. package/corpus/templates/brain/app/routes/sources.tsx +1 -1
  11. package/corpus/templates/brain/vitest.config.ts +7 -0
  12. package/corpus/templates/calendar/CHANGELOG.md +6 -0
  13. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +1 -1
  14. package/corpus/templates/calendar/changelog/2026-10-16-booking-links-show-localized-visibility-badges.md +6 -0
  15. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +62 -101
  16. package/corpus/templates/clips/CHANGELOG.md +7 -20
  17. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +20 -77
  18. package/corpus/templates/clips/app/components/player/share-dialog.tsx +63 -90
  19. package/corpus/templates/clips/app/components/sharing/share-test-support.tsx +180 -0
  20. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +5 -584
  21. package/corpus/templates/clips/changelog/2026-10-16-share-dialogs-make-it-easier-to-see-who-has-access-and-manag.md +6 -0
  22. package/corpus/templates/clips/changelog/2026-10-21-share-panels-describe-general-access-in-a-sentence.md +6 -0
  23. package/corpus/templates/community/qr/package.json +1 -1
  24. package/corpus/templates/content/CHANGELOG.md +7 -0
  25. package/corpus/templates/content/app/components/editor/CommentStack.tsx +301 -0
  26. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +770 -179
  27. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +75 -27
  28. package/corpus/templates/content/app/components/editor/SuggestionText.tsx +18 -3
  29. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +89 -22
  30. package/corpus/templates/content/app/components/editor/block-field-comment-threads.ts +15 -0
  31. package/corpus/templates/content/app/components/editor/block-field-comments.tsx +5 -9
  32. package/corpus/templates/content/app/components/editor/comment-stacks.ts +143 -0
  33. package/corpus/templates/content/app/components/editor/extensions/SuggestionHighlight.ts +383 -34
  34. package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +31 -0
  35. package/corpus/templates/content/app/components/editor/suggestions/presentation-rebase.ts +82 -4
  36. package/corpus/templates/content/app/global.css +31 -0
  37. package/corpus/templates/content/app/i18n-data.ts +8 -0
  38. package/corpus/templates/content/changelog/2026-10-16-accepting-a-suggestion-on-a-page-that-changed-since-it-was-s.md +6 -0
  39. package/corpus/templates/content/changelog/2026-10-20-suggested-edits-that-add-remove-split-or-merge-paragraphs-li.md +6 -0
  40. package/corpus/templates/content/changelog/2026-10-22-blocks-with-several-comment-threads-fold-into-one-stack-in-t.md +6 -0
  41. package/corpus/templates/content/changelog/2026-10-22-opening-a-page-no-longer-saves-an-edit-again-after-another-o.md +6 -0
  42. package/corpus/templates/content/docs/product/capabilities/content.comment.page-owned.md +8 -1
  43. package/corpus/templates/content/shared/suggestion-diff.ts +23 -6
  44. package/corpus/templates/content/shared/suggestion-formatting.ts +44 -14
  45. package/corpus/templates/content/shared/suggestion-text.ts +20 -5
  46. package/corpus/templates/design/.generated/bridge/interact-links.generated.ts +8 -8
  47. package/corpus/templates/design/CHANGELOG.md +8 -0
  48. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +18 -1
  49. package/corpus/templates/design/app/components/design/bridge/interact-links.bridge.ts +8 -8
  50. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +15 -13
  51. package/corpus/templates/design/app/i18n/en-US.ts +1 -1
  52. package/corpus/templates/design/app/pages/DesignSystems.tsx +1 -1
  53. package/corpus/templates/design/app/pages/Index.tsx +18 -64
  54. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-modes.ts +15 -6
  55. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-screen-rendering.tsx +29 -5
  56. package/corpus/templates/design/app/pages/design-editor/prototype-navigation-transition.ts +423 -0
  57. package/corpus/templates/design/app/pages/design-editor/view/single-screen-canvas.tsx +35 -9
  58. package/corpus/templates/design/changelog/2026-10-16-design-pages-use-localized-visibility-badges-and-a-shared-ow.md +6 -0
  59. package/corpus/templates/design/changelog/2026-10-20-the-design-full-app-toggle-on-the-home-page-no-longer-hides-.md +6 -0
  60. package/corpus/templates/design/changelog/2026-10-22-prototype-links-can-play-code-authored-screen-transitions.md +6 -0
  61. package/corpus/templates/design/shared/motion-authored-css-import.ts +938 -7
  62. package/corpus/templates/design/shared/motion-html-source.ts +1689 -135
  63. package/corpus/templates/design/shared/motion-prototype-transition.ts +329 -0
  64. package/corpus/templates/design/shared/motion-react-source.ts +2154 -126
  65. package/corpus/templates/design/shared/motion-text-runtime-v1.fixture.txt +221 -0
  66. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  67. package/corpus/templates/dispatch/app/i18n-data.ts +75 -0
  68. package/corpus/templates/dispatch/changelog/2026-10-21-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  69. package/corpus/templates/factory/app/components/layout/Sidebar.tsx +34 -100
  70. package/corpus/templates/forms/app/pages/FormsListPage.tsx +1 -1
  71. package/corpus/templates/plan/CHANGELOG.md +6 -0
  72. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +34 -109
  73. package/corpus/templates/plan/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  74. package/corpus/templates/slides/CHANGELOG.md +1 -0
  75. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +1 -1
  76. package/corpus/templates/slides/app/components/design-system/DesignSystemCard.tsx +1 -1
  77. package/corpus/templates/slides/app/pages/Index.tsx +7 -2
  78. package/corpus/templates/slides/changelog/2026-10-16-deck-lists-use-the-shared-ownership-filter-and-visibility-ba.md +6 -0
  79. package/package.json +1 -1
  80. package/corpus/templates/chat/app/lib/sidebar-thread-state.ts +0 -29
  81. package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +0 -83
@@ -327,6 +327,8 @@ import {
327
327
  planSuggestionDraftPersistence,
328
328
  saveUnlessSuggestionChanged,
329
329
  suggestionAmendmentIdempotencyKey,
330
+ suggestionProposalCreationIdempotencyKey,
331
+ forgetSuggestionProposalCreationIdempotencyKey,
330
332
  type DraftSuggestion,
331
333
  type SuggestionDraftSession,
332
334
  type SuggestionPersistenceEntry,
@@ -336,11 +338,13 @@ import {
336
338
  import { suggestedEditorIsolation } from "./suggestions/editor-isolation";
337
339
  import { useRememberedEditorMode } from "./suggestions/editor-mode-memory";
338
340
  import {
341
+ createDraftSuggestionPresentationTransition,
339
342
  createObservedSuggestionPresentationTransition,
340
343
  hydrateSuggestionPresentationTransitions,
341
344
  preciseSuggestionPresentationOperations,
342
345
  retainCommittedSuggestionPresentationTransitions,
343
346
  resolveSuggestionPresentationRange,
347
+ resolveSuggestionPresentationRangeInDraft,
344
348
  suggestionPresentationTransitionKey,
345
349
  type SuggestionPresentationTransition,
346
350
  type SuggestionPresentationTransitions,
@@ -635,6 +639,7 @@ export function suggestionPresentation(
635
639
  currentMarkdown: string,
636
640
  transition?: SuggestionPresentationTransition,
637
641
  observedTransition?: SuggestionPresentationTransition,
642
+ draftTransition?: SuggestionPresentationTransition,
638
643
  ): VisualEditorSuggestion | null {
639
644
  if (suggestion.status !== "pending") return null;
640
645
  const operation = suggestion.operations[0];
@@ -671,12 +676,22 @@ export function suggestionPresentation(
671
676
  ) {
672
677
  return null;
673
678
  }
674
- const range = resolveSuggestionPresentationRange(
679
+ const canonicalRange = resolveSuggestionPresentationRange(
675
680
  currentMarkdown,
676
681
  operation,
677
682
  transition,
678
683
  observedTransition,
679
684
  );
685
+ const range =
686
+ canonicalRange ??
687
+ (draftTransition?.after === currentMarkdown
688
+ ? resolveSuggestionPresentationRangeInDraft(
689
+ draftTransition,
690
+ operation,
691
+ transition,
692
+ observedTransition,
693
+ )
694
+ : null);
680
695
  if (!range) return null;
681
696
  const editorMarkdown = canonicalizeNfm(currentMarkdown);
682
697
  const currentText = currentMarkdown.slice(range.from, range.to);
@@ -694,7 +709,9 @@ export function suggestionPresentation(
694
709
  return {
695
710
  id: suggestion.id,
696
711
  kind: operation.kind as VisualEditorSuggestion["kind"],
697
- canonicalOperation: operation,
712
+ // The editor re-resolves a canonical operation on its own text, where only
713
+ // the draft carries this one, so the anchor places it instead.
714
+ canonicalOperation: canonicalRange ? operation : undefined,
698
715
  canonicalTransition: transition,
699
716
  observedTransition,
700
717
  beforeText: before.changedText,
@@ -726,6 +743,7 @@ export function suggestionPresentations(
726
743
  currentMarkdown: string,
727
744
  transition?: SuggestionPresentationTransition,
728
745
  observedTransition?: SuggestionPresentationTransition,
746
+ draftTransition?: SuggestionPresentationTransition,
729
747
  ): VisualEditorSuggestion[] {
730
748
  // Decided edits have no presentation; skip their full-Page diff.
731
749
  if (suggestion.status !== "pending") return [];
@@ -734,6 +752,7 @@ export function suggestionPresentations(
734
752
  currentMarkdown,
735
753
  transition,
736
754
  observedTransition,
755
+ draftTransition,
737
756
  );
738
757
  if (suggestion.operations.length !== 1) return original ? [original] : [];
739
758
  const saved = suggestion.operations[0]!;
@@ -746,6 +765,7 @@ export function suggestionPresentations(
746
765
  currentMarkdown,
747
766
  transition,
748
767
  observedTransition,
768
+ draftTransition,
749
769
  ),
750
770
  );
751
771
  return precise.every((presentation) => presentation !== null)
@@ -764,6 +784,13 @@ export function documentEditorSuggestionPresentations(args: {
764
784
  transitions: SuggestionPresentationTransitions;
765
785
  observedTransition?: SuggestionPresentationTransition;
766
786
  }): VisualEditorSuggestion[] {
787
+ const draftTransition =
788
+ args.savedSuggestions.length > 0
789
+ ? (createDraftSuggestionPresentationTransition(
790
+ args.drafts,
791
+ args.currentMarkdown,
792
+ ) ?? undefined)
793
+ : undefined;
767
794
  const savedPresentations: VisualEditorSuggestion[] = [];
768
795
  for (const suggestion of args.savedSuggestions) {
769
796
  if (suggestion.id === args.editingSuggestionId) continue;
@@ -773,6 +800,7 @@ export function documentEditorSuggestionPresentations(args: {
773
800
  args.currentMarkdown,
774
801
  args.transitions.get(suggestionPresentationTransitionKey(suggestion)),
775
802
  args.observedTransition,
803
+ draftTransition,
776
804
  ),
777
805
  );
778
806
  }
@@ -4160,6 +4188,15 @@ function PageEditorSessionBody({
4160
4188
  options.editGeneration ?? editorEditGenerationRef.current;
4161
4189
  const contentObservationEpoch =
4162
4190
  options.contentObservationEpoch ?? contentObservationEpochRef.current;
4191
+ // Text another writer added while this save was in flight is theirs to
4192
+ // save, so it holds back neither the confirmation nor the journal clear.
4193
+ // An entry kept for it outlives this tab's edits, and the next tab to
4194
+ // open the page replays it as a save of its own.
4195
+ const settleConfirmedEdit = () => {
4196
+ if (contentEditVersionRef.current !== contentEditVersion) return;
4197
+ confirmedContentEditVersionRef.current = contentEditVersion;
4198
+ clearConfirmedDraftJournal(editorEditGeneration);
4199
+ };
4163
4200
  lastSavedContentRef.current = refreshUnchangedContentSaveWatermark({
4164
4201
  serverContent: documentContentRef.current,
4165
4202
  serverUpdatedAt: documentUpdatedAtRef.current,
@@ -4240,12 +4277,14 @@ function PageEditorSessionBody({
4240
4277
  { title, content },
4241
4278
  options,
4242
4279
  );
4243
- return {
4244
- contentPersisted:
4245
- !isDocumentUpdateConflict(attested) &&
4246
- !isDocumentUpdateSuperseded(attested) &&
4247
- !isDocumentUpdatePreservationRequired(attested),
4248
- };
4280
+ const confirmed =
4281
+ !isDocumentUpdateConflict(attested) &&
4282
+ !isDocumentUpdateSuperseded(attested) &&
4283
+ !isDocumentUpdatePreservationRequired(attested);
4284
+ // The page already held this edit, as when a peer's save carried it,
4285
+ // so the attestation settles it as a written save would.
4286
+ if (confirmed) settleConfirmedEdit();
4287
+ return { contentPersisted: confirmed };
4249
4288
  }
4250
4289
  if (!hasUpdates) {
4251
4290
  return { contentPersisted: !contentChanged };
@@ -4465,14 +4504,7 @@ function PageEditorSessionBody({
4465
4504
  : null,
4466
4505
  });
4467
4506
  }
4468
- // Text another writer added while this save was in flight is theirs to
4469
- // save, so it holds back neither the confirmation nor the journal clear.
4470
- // An entry kept for it outlives this tab's edits, and the next tab to
4471
- // open the page replays it as a save of its own.
4472
- if (contentEditVersionRef.current === contentEditVersion) {
4473
- confirmedContentEditVersionRef.current = contentEditVersion;
4474
- clearConfirmedDraftJournal(editorEditGeneration);
4475
- }
4507
+ settleConfirmedEdit();
4476
4508
 
4477
4509
  if (autoSync) {
4478
4510
  const status = queryClient.getQueryData<DocumentSyncStatus>(
@@ -5906,6 +5938,10 @@ function PageEditorSessionBody({
5906
5938
  unresolved.request.idempotencyKey,
5907
5939
  unresolved.baseId,
5908
5940
  );
5941
+ forgetSuggestionProposalCreationIdempotencyKey(
5942
+ suggestionProposalCreationKeysRef.current,
5943
+ unresolved.request,
5944
+ );
5909
5945
  unresolvedProposalCreationRef.current = null;
5910
5946
  }
5911
5947
  const operations = suggestionDraftOperations(base, draft);
@@ -6086,29 +6122,28 @@ function PageEditorSessionBody({
6086
6122
  if (create.length > 0) {
6087
6123
  const pending = create.map(({ operation }) => operation);
6088
6124
  const pendingKeys = create.map(({ key }) => key);
6089
- const requestKey = JSON.stringify([base.id, pendingKeys]);
6090
- const idempotencyKey =
6091
- suggestionProposalCreationKeysRef.current.get(requestKey) ??
6092
- globalThis.crypto.randomUUID();
6093
- suggestionProposalCreationKeysRef.current.set(
6094
- requestKey,
6095
- idempotencyKey,
6096
- );
6097
6125
  const existingProposal = suggestionProposalsRef.current.get(base.id);
6098
6126
  const proposalSummary =
6099
6127
  existingProposal?.summary ?? t("editor.toolbar.suggestEdits");
6100
- const request = {
6128
+ const fields = {
6101
6129
  resourceType: "document",
6102
6130
  resourceId: documentId,
6103
6131
  adapterKind: "content.document-markdown",
6104
6132
  baseRevision: base.baseRevision,
6105
6133
  summary: proposalSummary,
6106
6134
  proposalId: existingProposal?.id,
6107
- idempotencyKey,
6108
6135
  suggestions: pending.map((operation) => ({
6109
6136
  summary: proposalSummary,
6110
6137
  operations: [operation],
6111
6138
  })),
6139
+ };
6140
+ const idempotencyKey = suggestionProposalCreationIdempotencyKey(
6141
+ suggestionProposalCreationKeysRef.current,
6142
+ fields,
6143
+ );
6144
+ const request = {
6145
+ ...fields,
6146
+ idempotencyKey,
6112
6147
  } satisfies Parameters<
6113
6148
  typeof createSuggestionProposal.mutateAsync
6114
6149
  >[0];
@@ -6120,6 +6155,10 @@ function PageEditorSessionBody({
6120
6155
  };
6121
6156
  const created = await createSuggestionProposal.mutateAsync(request);
6122
6157
  recordCreated(created, pendingKeys, pending, idempotencyKey, base.id);
6158
+ forgetSuggestionProposalCreationIdempotencyKey(
6159
+ suggestionProposalCreationKeysRef.current,
6160
+ request,
6161
+ );
6123
6162
  unresolvedProposalCreationRef.current = null;
6124
6163
  pendingKeys.forEach((operationKey, index) => {
6125
6164
  persisted.set(operationKey, created.suggestions[index]!);
@@ -6135,7 +6174,14 @@ function PageEditorSessionBody({
6135
6174
  status < 500 &&
6136
6175
  status !== 408 &&
6137
6176
  status !== 429;
6138
- if (clientError) unresolvedProposalCreationRef.current = null;
6177
+ const refused = clientError && unresolvedProposalCreationRef.current;
6178
+ if (refused) {
6179
+ forgetSuggestionProposalCreationIdempotencyKey(
6180
+ suggestionProposalCreationKeysRef.current,
6181
+ refused.request,
6182
+ );
6183
+ unresolvedProposalCreationRef.current = null;
6184
+ }
6139
6185
  adoptConfirmedSuggestions();
6140
6186
  if (error instanceof SuggestionFormattingMappingError) {
6141
6187
  // The draft keeps its persistent unsupported-formatting alert.
@@ -8650,7 +8696,9 @@ function PageEditorSessionBody({
8650
8696
  onActivateSuggestion={activateSuggestionOrThread}
8651
8697
  onSelectedThreadChange={setSelectedThreadId}
8652
8698
  onHoveredThreadChange={setHoveredThreadId}
8699
+ hoveredThreadId={hoveredThreadId}
8653
8700
  currentUserEmail={session?.email}
8701
+ currentUserName={session?.name}
8654
8702
  currentUserOrgId={session?.orgId}
8655
8703
  canComment={canComment}
8656
8704
  canResolve={canEdit}
@@ -9,8 +9,9 @@ import { Fragment, type ReactNode } from "react";
9
9
 
10
10
  function renderNode(node: SuggestionPresentationNode, key: string): ReactNode {
11
11
  if (node.type === "text") return <Fragment key={key}>{node.value}</Fragment>;
12
- if (node.type === "indent")
12
+ if (node.type === "indent" || node.type === "block")
13
13
  return <Fragment key={key}>{node.value}</Fragment>;
14
+ if (node.type === "lineBreak") return <br key={key} />;
14
15
  const children = node.children.map((child, index) =>
15
16
  renderNode(child, `${key}-${index}`),
16
17
  );
@@ -42,16 +43,30 @@ export function SuggestionText({
42
43
  context?: SuggestionPresentationContext;
43
44
  }) {
44
45
  const t = useT();
45
- const nodes = context
46
+ const presented = context
46
47
  ? suggestionTextPresentationForSource(content, context)
47
48
  : suggestionTextPresentation(content);
48
- if (!nodes) {
49
+ if (!presented) {
49
50
  return (
50
51
  <span data-suggestion-text-unavailable="true">
51
52
  {t("editor.sourceComponent.previewUnavailable")}
52
53
  </span>
53
54
  );
54
55
  }
56
+ // A new paragraph's own line break would open or close the card with an
57
+ // empty line, so a break at either edge shows as its marker instead; the
58
+ // breaks between lines stay.
59
+ let start = 0;
60
+ let end = presented.length;
61
+ while (start < end && presented[start]!.type === "lineBreak") start += 1;
62
+ while (end > start && presented[end - 1]!.type === "lineBreak") end -= 1;
63
+ const marker = (count: number): SuggestionPresentationNode[] =>
64
+ count ? [{ type: "text", value: "↵".repeat(count) }] : [];
65
+ const nodes = [
66
+ ...marker(start),
67
+ ...presented.slice(start, end),
68
+ ...marker(presented.length - end),
69
+ ];
55
70
  return (
56
71
  <span className="whitespace-pre-wrap break-words">
57
72
  {nodes.map((node, index) => renderNode(node, String(index)))}
@@ -1439,7 +1439,26 @@ function suggestionAnchorRange(
1439
1439
  const collapsedRange = collapsedDeletionTextblock();
1440
1440
  if (collapsedRange) return collapsedRange;
1441
1441
  if (sourceMatches) {
1442
- const exactRange = sourceRangeToPm(sourceFrom, sourceTo);
1442
+ // An empty paragraph has no text to map, so a span that opens or closes
1443
+ // with one maps the text beside it, or the place it starts when that's
1444
+ // all it holds.
1445
+ const emptyLine = "<empty-block/>";
1446
+ let mapFrom = sourceFrom;
1447
+ let mapTo = sourceTo;
1448
+ for (;;) {
1449
+ if (
1450
+ mapTo - mapFrom > emptyLine.length &&
1451
+ source.slice(mapTo - emptyLine.length - 1, mapTo) === `\n${emptyLine}`
1452
+ )
1453
+ mapTo -= emptyLine.length + 1;
1454
+ else if (
1455
+ mapTo - mapFrom > emptyLine.length &&
1456
+ source.startsWith(`${emptyLine}\n`, mapFrom)
1457
+ )
1458
+ mapFrom += emptyLine.length + 1;
1459
+ else break;
1460
+ }
1461
+ const exactRange = sourceRangeToPm(mapFrom, mapTo);
1443
1462
  if (exactRange) return exactRange;
1444
1463
  }
1445
1464
  const mappedSource = suggestionAnchorText(source);
@@ -1592,11 +1611,18 @@ export function suggestionHighlightSpec(
1592
1611
  editableText: true,
1593
1612
  };
1594
1613
  }
1614
+ // Removed syntax with no text, such as indentation, a list marker, or an
1615
+ // empty paragraph, is not in the document text, so it is drawn instead.
1595
1616
  const structuralDeletion =
1596
1617
  /^\n+$/.test(suggestionAnchorText(suggestion.beforeText)) ||
1597
1618
  (beforePresentation &&
1598
1619
  beforePresentation.length > 0 &&
1599
- beforePresentation.every((node) => node.type === "indent"));
1620
+ beforePresentation.every(
1621
+ (node) =>
1622
+ node.type === "indent" ||
1623
+ node.type === "block" ||
1624
+ (node.type === "text" && /^↵+$/.test(node.value)),
1625
+ ));
1600
1626
  return {
1601
1627
  suggestionId: suggestion.id,
1602
1628
  kind:
@@ -4743,9 +4769,17 @@ export function VisualEditor({
4743
4769
  const apply = (report = true) => {
4744
4770
  if (editor.isDestroyed) return;
4745
4771
  const specs = suggestions
4746
- .map((suggestion) =>
4747
- suggestionHighlightSpec(editor.state.doc, suggestion),
4748
- )
4772
+ .map((suggestion) => {
4773
+ const spec = suggestionHighlightSpec(editor.state.doc, suggestion);
4774
+ // While suggesting, a saved suggestion's text outside the document
4775
+ // is somewhere to type beside, not a control to focus.
4776
+ return spec &&
4777
+ suggesting &&
4778
+ editable &&
4779
+ suggestion.presentation === "canonical"
4780
+ ? { ...spec, editableBoundary: true }
4781
+ : spec;
4782
+ })
4749
4783
  .filter((spec): spec is SuggestionHighlightSpec => spec !== null);
4750
4784
  if (report)
4751
4785
  onSuggestionAnchorsChange?.(
@@ -4771,8 +4805,10 @@ export function VisualEditor({
4771
4805
  };
4772
4806
  }, [
4773
4807
  activeSuggestionId,
4808
+ editable,
4774
4809
  editor,
4775
4810
  onSuggestionAnchorsChange,
4811
+ suggesting,
4776
4812
  suggestions,
4777
4813
  suggestionsSignature,
4778
4814
  showCommentIndicators,
@@ -4796,34 +4832,46 @@ export function VisualEditor({
4796
4832
  };
4797
4833
  }, [editor]);
4798
4834
 
4835
+ // Callers build the readbacks inline and render again on every report, so
4836
+ // these checks key on the readbacks' fields and report each divergent body
4837
+ // once: keyed on the objects, each report re-runs its check until React
4838
+ // aborts the render.
4839
+ const acceptedReadbackId = acceptedDecisionReadback?.id ?? null;
4840
+ const acceptedReadbackContent = acceptedDecisionReadback?.content ?? null;
4799
4841
  useLayoutEffect(() => {
4800
- if (!editor || editor.isDestroyed || !acceptedDecisionReadback) return;
4842
+ if (
4843
+ !editor ||
4844
+ editor.isDestroyed ||
4845
+ acceptedReadbackId === null ||
4846
+ acceptedReadbackContent === null
4847
+ )
4848
+ return;
4801
4849
  const settlingSuggestion = suggestions.find(
4802
- (suggestion) => suggestion.id === acceptedDecisionReadback.id,
4850
+ (suggestion) => suggestion.id === acceptedReadbackId,
4803
4851
  );
4804
4852
  if (!settlingSuggestion) return;
4853
+ let reported: string | null = null;
4805
4854
  const check = () => {
4806
4855
  if (editor.isDestroyed) return;
4807
4856
  const actualContent = docToNfm(editor.getJSON() as any);
4808
4857
  if (
4809
4858
  acceptedSuggestionRendered(
4810
4859
  actualContent,
4811
- acceptedDecisionReadback.content,
4860
+ acceptedReadbackContent,
4812
4861
  settlingSuggestion,
4813
4862
  )
4814
4863
  ) {
4815
- onAcceptedDecisionRendered?.(acceptedDecisionReadback.id);
4864
+ onAcceptedDecisionRendered?.(acceptedReadbackId);
4816
4865
  } else if (
4866
+ actualContent !== reported &&
4817
4867
  acceptedSuggestionReadbackOutdated(
4818
4868
  actualContent,
4819
- acceptedDecisionReadback.content,
4869
+ acceptedReadbackContent,
4820
4870
  settlingSuggestion,
4821
4871
  )
4822
4872
  ) {
4823
- onAcceptedDecisionReadbackOutdated?.(
4824
- acceptedDecisionReadback.id,
4825
- actualContent,
4826
- );
4873
+ reported = actualContent;
4874
+ onAcceptedDecisionReadbackOutdated?.(acceptedReadbackId, actualContent);
4827
4875
  }
4828
4876
  };
4829
4877
  editor.on("transaction", check);
@@ -4832,27 +4880,44 @@ export function VisualEditor({
4832
4880
  editor.off("transaction", check);
4833
4881
  };
4834
4882
  }, [
4835
- acceptedDecisionReadback,
4883
+ acceptedReadbackContent,
4884
+ acceptedReadbackId,
4836
4885
  editor,
4837
4886
  onAcceptedDecisionRendered,
4838
4887
  onAcceptedDecisionReadbackOutdated,
4839
4888
  suggestions,
4840
4889
  ]);
4841
4890
 
4891
+ const proposalReadbackGeneration =
4892
+ proposalDecisionReadback?.generation ?? null;
4893
+ const proposalReadbackContent = proposalDecisionReadback?.content ?? null;
4894
+ const proposalReadbackBefore =
4895
+ proposalDecisionReadback?.beforeContent ?? null;
4842
4896
  useLayoutEffect(() => {
4843
- if (!editor || editor.isDestroyed || !proposalDecisionReadback) return;
4897
+ if (
4898
+ !editor ||
4899
+ editor.isDestroyed ||
4900
+ proposalReadbackGeneration === null ||
4901
+ proposalReadbackContent === null ||
4902
+ proposalReadbackBefore === null
4903
+ )
4904
+ return;
4905
+ let reported: string | null = null;
4844
4906
  const check = () => {
4845
4907
  if (editor.isDestroyed) return;
4846
4908
  const actual = canonicalizeNfm(docToNfm(editor.getJSON() as any));
4847
- if (actual === canonicalizeNfm(proposalDecisionReadback.content))
4848
- onProposalDecisionRendered?.(proposalDecisionReadback.generation);
4909
+ if (actual === canonicalizeNfm(proposalReadbackContent))
4910
+ onProposalDecisionRendered?.(proposalReadbackGeneration);
4849
4911
  else if (
4850
- actual !== canonicalizeNfm(proposalDecisionReadback.beforeContent)
4851
- )
4912
+ actual !== reported &&
4913
+ actual !== canonicalizeNfm(proposalReadbackBefore)
4914
+ ) {
4915
+ reported = actual;
4852
4916
  onProposalDecisionReadbackOutdated?.(
4853
- proposalDecisionReadback.generation,
4917
+ proposalReadbackGeneration,
4854
4918
  actual,
4855
4919
  );
4920
+ }
4856
4921
  };
4857
4922
  editor.on("transaction", check);
4858
4923
  check();
@@ -4863,7 +4928,9 @@ export function VisualEditor({
4863
4928
  editor,
4864
4929
  onProposalDecisionReadbackOutdated,
4865
4930
  onProposalDecisionRendered,
4866
- proposalDecisionReadback,
4931
+ proposalReadbackBefore,
4932
+ proposalReadbackContent,
4933
+ proposalReadbackGeneration,
4867
4934
  ]);
4868
4935
 
4869
4936
  useEffect(() => {
@@ -16,6 +16,21 @@ export function commentThreadFieldId(
16
16
  return thread.field?.fieldId ?? null;
17
17
  }
18
18
 
19
+ /**
20
+ * The threads the margin shows with one tab open: that tab's and the
21
+ * Property threads. A page without tabs shows them all.
22
+ */
23
+ export function commentRailThreads<
24
+ T extends Pick<CommentThread, "field" | "property">,
25
+ >(threads: readonly T[], openFieldId: string | null): T[] {
26
+ if (openFieldId === null && !threads.some((thread) => thread.field)) {
27
+ return threads as T[];
28
+ }
29
+ return threads.filter(
30
+ (thread) => thread.property || commentThreadFieldId(thread) === openFieldId,
31
+ );
32
+ }
33
+
19
34
  /** A key for a tab that a Map or a filter can hold: "" is the page body. */
20
35
  export function commentTabKey(fieldId: string | null): string {
21
36
  return fieldId ?? "";
@@ -14,6 +14,7 @@ import {
14
14
  import type { CommentThread } from "@/hooks/use-comments";
15
15
 
16
16
  import {
17
+ commentRailThreads,
17
18
  commentThreadFieldId,
18
19
  openReviewCountsByField,
19
20
  type BlockFieldOpenCounts,
@@ -353,15 +354,10 @@ export function useBlockFieldComments({
353
354
  }, [scrollContainerRef, tabs, targetFieldId, targetKey, targetSettled]);
354
355
 
355
356
  const allThreads = threads ?? NO_THREADS;
356
- const railThreads = useMemo(() => {
357
- if (openFieldId === null && !allThreads.some((thread) => thread.field)) {
358
- return allThreads as CommentThread[];
359
- }
360
- return allThreads.filter(
361
- (thread) =>
362
- thread.property || commentThreadFieldId(thread) === openFieldId,
363
- );
364
- }, [allThreads, openFieldId]);
357
+ const railThreads = useMemo(
358
+ () => commentRailThreads(allThreads as CommentThread[], openFieldId),
359
+ [allThreads, openFieldId],
360
+ );
365
361
 
366
362
  const pendingFieldId = pendingComment?.property
367
363
  ? undefined
@@ -0,0 +1,143 @@
1
+ import type { CommentThread } from "@/hooks/use-comments";
2
+
3
+ import {
4
+ commentTabKey,
5
+ commentThreadFieldId,
6
+ } from "./block-field-comment-threads";
7
+ import { inHiddenBlockFieldPanel } from "./blockFieldTabs";
8
+
9
+ /** Where a thread's first highlight sits: its stack, and its document order. */
10
+ export type CommentBlockPlacement = { key: string; order: number };
11
+
12
+ /** Two or more open threads in one block, in document order. */
13
+ export type CommentStack<T> = { key: string; threads: T[] };
14
+
15
+ /**
16
+ * The block a highlight is in, as child indexes down from its editor: the
17
+ * innermost list item holding it, else the editor's top-level node. A
18
+ * highlight outside an editor, such as a Property's, has no block.
19
+ */
20
+ export function commentBlockKey(highlight: Element): string | null {
21
+ const block = commentBlock(highlight);
22
+ return block ? blockPath(block) : null;
23
+ }
24
+
25
+ function commentBlock(highlight: Element) {
26
+ const editor = highlight.closest(".ProseMirror");
27
+ if (!editor) return null;
28
+ const item = highlight.closest("li");
29
+ if (item && editor.contains(item)) return { editor, block: item };
30
+ let block: Element | null = highlight;
31
+ while (block && block.parentElement !== editor) block = block.parentElement;
32
+ return block ? { editor, block } : null;
33
+ }
34
+
35
+ function blockPath({ editor, block }: { editor: Element; block: Element }) {
36
+ const path: number[] = [];
37
+ for (let node: Element = block; node !== editor; ) {
38
+ const parent: Element | null = node.parentElement;
39
+ if (!parent) return null;
40
+ path.unshift(Array.prototype.indexOf.call(parent.children, node));
41
+ node = parent;
42
+ }
43
+ return path.join(".");
44
+ }
45
+
46
+ /** Block keys repeat across Blocks field tabs, so a stack's key has both. */
47
+ export function commentStackKey(fieldId: string | null, blockKey: string) {
48
+ return `${commentTabKey(fieldId)}:${blockKey}`;
49
+ }
50
+
51
+ /**
52
+ * Each thread's first highlight on the page, in document order. Property
53
+ * threads, threads not listed, and highlights in a closed tab are left out.
54
+ */
55
+ export function measureCommentBlocks(
56
+ container: ParentNode,
57
+ threads: readonly CommentThread[],
58
+ ): Map<string, CommentBlockPlacement> {
59
+ const byId = new Map(
60
+ threads
61
+ .filter((thread) => !thread.property)
62
+ .map((thread) => [thread.threadId, thread]),
63
+ );
64
+ const placements = new Map<string, CommentBlockPlacement>();
65
+ // Highlights share blocks, and a block's path walks its siblings.
66
+ const paths = new Map<Element, string | null>();
67
+ for (const highlight of container.querySelectorAll("[data-comment-thread]")) {
68
+ const threadId = highlight.getAttribute("data-comment-thread");
69
+ const thread = threadId ? byId.get(threadId) : undefined;
70
+ if (!thread || placements.has(thread.threadId)) continue;
71
+ if (inHiddenBlockFieldPanel(highlight)) continue;
72
+ const found = commentBlock(highlight);
73
+ if (!found) continue;
74
+ let block = paths.get(found.block);
75
+ if (block === undefined) {
76
+ block = blockPath(found);
77
+ paths.set(found.block, block);
78
+ }
79
+ if (block === null) continue;
80
+ placements.set(thread.threadId, {
81
+ key: commentStackKey(commentThreadFieldId(thread), block),
82
+ order: placements.size,
83
+ });
84
+ }
85
+ return placements;
86
+ }
87
+
88
+ /** The blocks holding two or more open threads, in document order. */
89
+ export function groupCommentStacks<
90
+ T extends Pick<CommentThread, "threadId" | "resolved" | "property">,
91
+ >(
92
+ threads: readonly T[],
93
+ placements: ReadonlyMap<string, CommentBlockPlacement>,
94
+ ): CommentStack<T>[] {
95
+ const groups = new Map<string, T[]>();
96
+ for (const thread of threads) {
97
+ if (thread.resolved || thread.property) continue;
98
+ const placement = placements.get(thread.threadId);
99
+ if (!placement) continue;
100
+ const members = groups.get(placement.key) ?? [];
101
+ members.push(thread);
102
+ groups.set(placement.key, members);
103
+ }
104
+ const order = (thread: T) => placements.get(thread.threadId)?.order ?? 0;
105
+ return [...groups]
106
+ .filter(([, members]) => members.length >= 2)
107
+ .map(([key, members]) => ({
108
+ key,
109
+ threads: members.sort((left, right) => order(left) - order(right)),
110
+ }))
111
+ .sort((left, right) => order(left.threads[0]) - order(right.threads[0]));
112
+ }
113
+
114
+ function latestCommentTime(thread: Pick<CommentThread, "comments">) {
115
+ let latest = -Infinity;
116
+ for (const comment of thread.comments) {
117
+ const time = Date.parse(comment.created_at);
118
+ if (time > latest) latest = time;
119
+ }
120
+ return latest;
121
+ }
122
+
123
+ /**
124
+ * The thread a folded stack shows: the one last touched, else the one with
125
+ * the newest comment. Ties go to the first in document order.
126
+ */
127
+ export function commentStackFront<
128
+ T extends Pick<CommentThread, "threadId" | "comments">,
129
+ >(threads: readonly T[], touches: ReadonlyMap<string, number>): T {
130
+ let front = threads[0];
131
+ let frontTouch = touches.get(front.threadId) ?? -Infinity;
132
+ let frontTime = latestCommentTime(front);
133
+ for (const thread of threads.slice(1)) {
134
+ const touch = touches.get(thread.threadId) ?? -Infinity;
135
+ const time = latestCommentTime(thread);
136
+ if (touch > frontTouch || (touch === frontTouch && time > frontTime)) {
137
+ front = thread;
138
+ frontTouch = touch;
139
+ frontTime = time;
140
+ }
141
+ }
142
+ return front;
143
+ }