@parhelia/core 0.3.12788 → 0.3.12791

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 (176) hide show
  1. package/dist/components/ui/LanguageSelector.js +4 -1
  2. package/dist/components/ui/LanguageSelector.js.map +1 -1
  3. package/dist/components/ui/select.js +12 -1
  4. package/dist/components/ui/select.js.map +1 -1
  5. package/dist/editor/FieldHistory.js +2 -2
  6. package/dist/editor/FieldHistory.js.map +1 -1
  7. package/dist/editor/ai/InlineAiDialog.js +35 -8
  8. package/dist/editor/ai/InlineAiDialog.js.map +1 -1
  9. package/dist/editor/ai/terminal/components/AgentProjectProgress.d.ts +4 -3
  10. package/dist/editor/ai/terminal/components/AgentProjectProgress.js +34 -50
  11. package/dist/editor/ai/terminal/components/AgentProjectProgress.js.map +1 -1
  12. package/dist/editor/ai/terminal/components/AgentTerminalView.d.ts +2 -1
  13. package/dist/editor/ai/terminal/components/AgentTerminalView.js +2 -2
  14. package/dist/editor/ai/terminal/components/AgentTerminalView.js.map +1 -1
  15. package/dist/editor/ai/terminal/useAgentTerminalController.js +1 -0
  16. package/dist/editor/ai/terminal/useAgentTerminalController.js.map +1 -1
  17. package/dist/editor/ai/terminal/useAgentTerminalViewProps.d.ts +1 -0
  18. package/dist/editor/ai/terminal/useAgentTerminalViewProps.js +2 -1
  19. package/dist/editor/ai/terminal/useAgentTerminalViewProps.js.map +1 -1
  20. package/dist/editor/bridge/protocol.d.ts +4 -0
  21. package/dist/editor/bridge/protocol.js +1 -0
  22. package/dist/editor/bridge/protocol.js.map +1 -1
  23. package/dist/editor/client/EditorShell.js +174 -65
  24. package/dist/editor/client/EditorShell.js.map +1 -1
  25. package/dist/editor/client/editContext.d.ts +13 -3
  26. package/dist/editor/client/editContext.js.map +1 -1
  27. package/dist/editor/client/hooks/activeSessionSnapshots.d.ts +2 -0
  28. package/dist/editor/client/hooks/activeSessionSnapshots.js +43 -0
  29. package/dist/editor/client/hooks/activeSessionSnapshots.js.map +1 -0
  30. package/dist/editor/client/hooks/feedbackSocketState.js +3 -0
  31. package/dist/editor/client/hooks/feedbackSocketState.js.map +1 -1
  32. package/dist/editor/client/hooks/operationRefreshDeduplication.d.ts +22 -0
  33. package/dist/editor/client/hooks/operationRefreshDeduplication.js +45 -0
  34. package/dist/editor/client/hooks/operationRefreshDeduplication.js.map +1 -0
  35. package/dist/editor/client/hooks/socketMessageRetention.d.ts +7 -0
  36. package/dist/editor/client/hooks/socketMessageRetention.js +11 -0
  37. package/dist/editor/client/hooks/socketMessageRetention.js.map +1 -0
  38. package/dist/editor/client/hooks/useSocketMessageHandler.js +39 -19
  39. package/dist/editor/client/hooks/useSocketMessageHandler.js.map +1 -1
  40. package/dist/editor/client/hooks/useWorkbox.d.ts +4 -0
  41. package/dist/editor/client/hooks/useWorkbox.js +132 -2
  42. package/dist/editor/client/hooks/useWorkbox.js.map +1 -1
  43. package/dist/editor/client/itemsRepository.d.ts +7 -1
  44. package/dist/editor/client/itemsRepository.js +41 -18
  45. package/dist/editor/client/itemsRepository.js.map +1 -1
  46. package/dist/editor/client/operations.d.ts +7 -3
  47. package/dist/editor/client/operations.js +216 -150
  48. package/dist/editor/client/operations.js.map +1 -1
  49. package/dist/editor/client/pageModelBuilder.js +15 -3
  50. package/dist/editor/client/pageModelBuilder.js.map +1 -1
  51. package/dist/editor/client/sidebarAvailability.d.ts +4 -0
  52. package/dist/editor/client/sidebarAvailability.js +15 -0
  53. package/dist/editor/client/sidebarAvailability.js.map +1 -1
  54. package/dist/editor/commands/itemCommands.js +1 -1
  55. package/dist/editor/commands/itemCommands.js.map +1 -1
  56. package/dist/editor/field-types/RichTextEditorComponent.js +6 -1
  57. package/dist/editor/field-types/RichTextEditorComponent.js.map +1 -1
  58. package/dist/editor/field-types/SingleLineText.js +2 -3
  59. package/dist/editor/field-types/SingleLineText.js.map +1 -1
  60. package/dist/editor/field-types/richtext/components/ReactSlate.d.ts +6 -1
  61. package/dist/editor/field-types/richtext/components/ReactSlate.js +91 -5
  62. package/dist/editor/field-types/richtext/components/ReactSlate.js.map +1 -1
  63. package/dist/editor/field-types/singleLineTextValue.d.ts +3 -0
  64. package/dist/editor/field-types/singleLineTextValue.js +9 -0
  65. package/dist/editor/field-types/singleLineTextValue.js.map +1 -0
  66. package/dist/editor/menubar/ItemLanguageVersion.js +1 -1
  67. package/dist/editor/menubar/ItemLanguageVersion.js.map +1 -1
  68. package/dist/editor/menubar/VersionSelector.js +18 -6
  69. package/dist/editor/menubar/VersionSelector.js.map +1 -1
  70. package/dist/editor/menubar/WorkflowButton.js +8 -3
  71. package/dist/editor/menubar/WorkflowButton.js.map +1 -1
  72. package/dist/editor/menubar/toolbar-sections/EditControls.js +1 -1
  73. package/dist/editor/menubar/toolbar-sections/EditControls.js.map +1 -1
  74. package/dist/editor/notifications/NotificationCenter.js +26 -7
  75. package/dist/editor/notifications/NotificationCenter.js.map +1 -1
  76. package/dist/editor/notifications/useNotifications.d.ts +3 -1
  77. package/dist/editor/notifications/useNotifications.js +27 -9
  78. package/dist/editor/notifications/useNotifications.js.map +1 -1
  79. package/dist/editor/page-editor-chrome/BridgeInlineFormatOverlay.js +18 -0
  80. package/dist/editor/page-editor-chrome/BridgeInlineFormatOverlay.js.map +1 -1
  81. package/dist/editor/page-editor-chrome/InlineFieldHistoryButton.js +30 -8
  82. package/dist/editor/page-editor-chrome/InlineFieldHistoryButton.js.map +1 -1
  83. package/dist/editor/page-editor-chrome/VersionDiffHighlightings.js +30 -8
  84. package/dist/editor/page-editor-chrome/VersionDiffHighlightings.js.map +1 -1
  85. package/dist/editor/page-editor-chrome/useBridgeInlineEditing.d.ts +5 -3
  86. package/dist/editor/page-editor-chrome/useBridgeInlineEditing.js +111 -19
  87. package/dist/editor/page-editor-chrome/useBridgeInlineEditing.js.map +1 -1
  88. package/dist/editor/page-viewer/EditorForm.d.ts +2 -1
  89. package/dist/editor/page-viewer/EditorForm.js +41 -12
  90. package/dist/editor/page-viewer/EditorForm.js.map +1 -1
  91. package/dist/editor/page-viewer/PageViewer.d.ts +2 -0
  92. package/dist/editor/page-viewer/PageViewer.js +11 -2
  93. package/dist/editor/page-viewer/PageViewer.js.map +1 -1
  94. package/dist/editor/page-viewer/PageViewerFrame.js +237 -42
  95. package/dist/editor/page-viewer/PageViewerFrame.js.map +1 -1
  96. package/dist/editor/page-viewer/bridgeInlineAiSelection.d.ts +17 -0
  97. package/dist/editor/page-viewer/bridgeInlineAiSelection.js +80 -0
  98. package/dist/editor/page-viewer/bridgeInlineAiSelection.js.map +1 -0
  99. package/dist/editor/page-viewer/bridgeInlineEditCommit.d.ts +18 -0
  100. package/dist/editor/page-viewer/bridgeInlineEditCommit.js +24 -0
  101. package/dist/editor/page-viewer/bridgeInlineEditCommit.js.map +1 -0
  102. package/dist/editor/page-viewer/bridgeInlineEditSession.d.ts +3 -0
  103. package/dist/editor/page-viewer/bridgeInlineEditSession.js +30 -0
  104. package/dist/editor/page-viewer/bridgeInlineEditSession.js.map +1 -1
  105. package/dist/editor/page-viewer/bridgeItemChangeTarget.d.ts +9 -0
  106. package/dist/editor/page-viewer/bridgeItemChangeTarget.js +38 -0
  107. package/dist/editor/page-viewer/bridgeItemChangeTarget.js.map +1 -0
  108. package/dist/editor/page-viewer/bridgePointerDragRelease.d.ts +31 -0
  109. package/dist/editor/page-viewer/bridgePointerDragRelease.js +32 -0
  110. package/dist/editor/page-viewer/bridgePointerDragRelease.js.map +1 -0
  111. package/dist/editor/page-viewer/renderUrl.d.ts +8 -0
  112. package/dist/editor/page-viewer/renderUrl.js +10 -0
  113. package/dist/editor/page-viewer/renderUrl.js.map +1 -1
  114. package/dist/editor/personalization/PersonalizationPanel.js +52 -25
  115. package/dist/editor/personalization/PersonalizationPanel.js.map +1 -1
  116. package/dist/editor/reviews/Comment.js +3 -1
  117. package/dist/editor/reviews/Comment.js.map +1 -1
  118. package/dist/editor/reviews/CommentDisplayPopover.js +2 -0
  119. package/dist/editor/reviews/CommentDisplayPopover.js.map +1 -1
  120. package/dist/editor/reviews/Comments.js +16 -3
  121. package/dist/editor/reviews/Comments.js.map +1 -1
  122. package/dist/editor/reviews/SuggestedEdit.js +25 -2
  123. package/dist/editor/reviews/SuggestedEdit.js.map +1 -1
  124. package/dist/editor/reviews/SuggestionCommentThread.js +2 -0
  125. package/dist/editor/reviews/SuggestionCommentThread.js.map +1 -1
  126. package/dist/editor/reviews/applySuggestedEditActions.js +105 -25
  127. package/dist/editor/reviews/applySuggestedEditActions.js.map +1 -1
  128. package/dist/editor/reviews/commentState.d.ts +4 -0
  129. package/dist/editor/reviews/commentState.js +15 -0
  130. package/dist/editor/reviews/commentState.js.map +1 -0
  131. package/dist/editor/reviews/feedbackOrdering.d.ts +1 -0
  132. package/dist/editor/reviews/feedbackOrdering.js +6 -0
  133. package/dist/editor/reviews/feedbackOrdering.js.map +1 -1
  134. package/dist/editor/reviews/suggestionContentChange.d.ts +7 -0
  135. package/dist/editor/reviews/suggestionContentChange.js +10 -0
  136. package/dist/editor/reviews/suggestionContentChange.js.map +1 -1
  137. package/dist/editor/reviews/suggestionPatch.js +6 -0
  138. package/dist/editor/reviews/suggestionPatch.js.map +1 -1
  139. package/dist/editor/services/personalizationService.js +30 -6
  140. package/dist/editor/services/personalizationService.js.map +1 -1
  141. package/dist/editor/services/suggestedEditsService.d.ts +1 -0
  142. package/dist/editor/services/suggestedEditsService.js +3 -0
  143. package/dist/editor/services/suggestedEditsService.js.map +1 -1
  144. package/dist/editor/settings/SettingsView.js +15 -7
  145. package/dist/editor/settings/SettingsView.js.map +1 -1
  146. package/dist/editor/settings/panels/MemoriesPanel.js +3 -2
  147. package/dist/editor/settings/panels/MemoriesPanel.js.map +1 -1
  148. package/dist/editor/settings/panels/ProjectTemplatesPanel.d.ts +3 -0
  149. package/dist/editor/settings/panels/ProjectTemplatesPanel.js +84 -15
  150. package/dist/editor/settings/panels/ProjectTemplatesPanel.js.map +1 -1
  151. package/dist/editor/settings/panels/projectTemplateDuplication.d.ts +5 -0
  152. package/dist/editor/settings/panels/projectTemplateDuplication.js +52 -0
  153. package/dist/editor/settings/panels/projectTemplateDuplication.js.map +1 -0
  154. package/dist/editor/ui/PerfectTree.js +2 -2
  155. package/dist/editor/ui/PerfectTree.js.map +1 -1
  156. package/dist/editor/ui/PublishRestrictionsDialog.js +9 -1
  157. package/dist/editor/ui/PublishRestrictionsDialog.js.map +1 -1
  158. package/dist/editor/utils.js +5 -2
  159. package/dist/editor/utils.js.map +1 -1
  160. package/dist/editor/views/EditorSlot.js +5 -1
  161. package/dist/editor/views/EditorSlot.js.map +1 -1
  162. package/dist/editor/views/SingleEditView.d.ts +4 -1
  163. package/dist/editor/views/SingleEditView.js +2 -2
  164. package/dist/editor/views/SingleEditView.js.map +1 -1
  165. package/dist/licensing/LicenseContext.js +7 -0
  166. package/dist/licensing/LicenseContext.js.map +1 -1
  167. package/dist/revision.d.ts +2 -2
  168. package/dist/revision.js +2 -2
  169. package/dist/task-board/TaskBoardWorkspace.js +5 -1
  170. package/dist/task-board/TaskBoardWorkspace.js.map +1 -1
  171. package/dist/task-board/taskExecutionRecords.js +7 -0
  172. package/dist/task-board/taskExecutionRecords.js.map +1 -1
  173. package/dist/task-board/views/DependencyGraphView.d.ts +1 -0
  174. package/dist/task-board/views/DependencyGraphView.js +15 -12
  175. package/dist/task-board/views/DependencyGraphView.js.map +1 -1
  176. package/package.json +1 -1
@@ -7,7 +7,7 @@ import { handleErrorResult } from "./helpers";
7
7
  import { isMissingEditOperationResult } from "./editHistoryReconciliation";
8
8
  import { useDebouncedCallback } from "use-debounce";
9
9
  import { createOrUpdateSuggestedEdit, deleteSuggestedEdit, } from "../services/suggestedEditsService";
10
- import { hasSuggestionContentChange, shouldRemoveNoOpSuggestion, } from "../reviews/suggestionContentChange";
10
+ import { hasLiveFieldDivergedFromSuggestion, hasSuggestionContentChange, shouldRemoveNoOpSuggestion, } from "../reviews/suggestionContentChange";
11
11
  import { GUID_REGEX_EXACT, getItemDescriptor } from "../utils";
12
12
  import { cleanId } from "../utils/id-helper";
13
13
  import { copyItemsNeedsTerminalCompletion, normalizeOpId, waitForEditOperationTerminal, } from "./waitForEditOperationTerminal";
@@ -18,6 +18,7 @@ import { preferLatestLocalUndoTarget } from "./undoTargetResolution";
18
18
  import { logFieldValueDiagnostic, summarizeFieldDescriptor, summarizeFieldValue, } from "../fieldValueDiagnostics";
19
19
  import { getSuggestedEditItemLanguage, getSuggestedEditItemVersion, getSuggestionFieldIdentityKey, } from "../reviews/suggestionDisplayValue";
20
20
  import { enqueueSuggestedEditFieldTask, waitForSuggestedEditFieldTasks, } from "./suggestedEditFieldQueue";
21
+ import { markNextBridgeRefreshAuthoritative } from "../page-viewer/bridgeInlineEditCommit";
21
22
  /** When true, the delete dialog shows "permanently delete (bypass recycle bin)". */
22
23
  const ENABLE_HARD_DELETE_IN_DELETE_DIALOG = false;
23
24
  // Track pending suggested edits to prevent creating duplicates before server response
@@ -378,7 +379,7 @@ export function getOperationsContext(state, ui) {
378
379
  }
379
380
  return true;
380
381
  };
381
- const ensureLock = useCallback(async (field) => {
382
+ const ensureLock = useCallback(async (field, options) => {
382
383
  if (!field || !field.item) {
383
384
  return false;
384
385
  }
@@ -386,7 +387,7 @@ export function getOperationsContext(state, ui) {
386
387
  const locallyAcquiredLock = acquiredFieldLockRef.current;
387
388
  const isAlreadyLocked = fieldsMatchForLock(stateRef.current.lockedField, field) ||
388
389
  fieldsMatchForLock(locallyAcquiredLock, field);
389
- if (isAlreadyLocked) {
390
+ if (!options?.force && isAlreadyLocked) {
390
391
  return true;
391
392
  }
392
393
  const existingPendingLock = pendingFieldLocks.current.get(lockKey);
@@ -514,6 +515,11 @@ export function getOperationsContext(state, ui) {
514
515
  const lastEditField = useRef(undefined);
515
516
  const pendingFieldSaves = useRef(new Map());
516
517
  const fieldEditGeneration = useRef(0);
518
+ // A blur is an undo-history boundary even when the blurred field's debounced
519
+ // save is still resolving. Capture this generation when an edit starts so a
520
+ // newer focus/edit cannot reuse the operation created by that older save.
521
+ const fieldOperationBoundary = useRef(0);
522
+ const lastOpBoundary = useRef(undefined);
517
523
  const completedFieldSaveGeneration = useRef(0);
518
524
  const latestSavedFieldEditOperationId = useRef(undefined);
519
525
  const getFieldSaveKey = (field) => {
@@ -548,9 +554,82 @@ export function getOperationsContext(state, ui) {
548
554
  ? stateRef.current.getFieldModificationStoreForSlot?.(activeSlotId)
549
555
  : undefined;
550
556
  }, []);
551
- const editField = useCallback(async ({ field, value, rawValue, forceMode, refresh = "immediate", originatingSlotId, }) => {
557
+ const queueSuggestedEditSave = useCallback((fieldKey, pendingFieldKey, op) => {
558
+ const saveSnapshot = { ...op };
559
+ const expectedNewValue = saveSnapshot.newValue;
560
+ return enqueueSuggestedEditFieldTask(fieldKey, () => {
561
+ const persistedVersion = persistedSuggestedEditVersions.get(fieldKey);
562
+ const snapshotForSave = persistedVersion?.id === saveSnapshot.id
563
+ ? { ...saveSnapshot, updated: persistedVersion.updated }
564
+ : saveSnapshot;
565
+ return createDelayedSuggestedEditSave(snapshotForSave, persistedVersion?.id === saveSnapshot.id
566
+ ? persistedVersion.updated
567
+ : undefined);
568
+ })
569
+ .then((result) => {
570
+ if (result.type === "success" && result.data) {
571
+ const updatedEdit = result.data;
572
+ if (updatedEdit.updated) {
573
+ persistedSuggestedEditVersions.set(fieldKey, {
574
+ id: updatedEdit.id,
575
+ updated: updatedEdit.updated,
576
+ });
577
+ }
578
+ if (isSuggestedEditRemoved(updatedEdit.id)) {
579
+ void deleteSuggestedEdit(updatedEdit).then((deleteResult) => {
580
+ if (deleteResult.type !== "success") {
581
+ console.warn("Failed to delete reverted suggestion after save:", deleteResult.summary, deleteResult.details);
582
+ }
583
+ }, () => undefined);
584
+ return result;
585
+ }
586
+ stateRef.current.setSuggestedEdits((current) => upsertSuggestedEdit(current, updatedEdit, {
587
+ expectedNewValue,
588
+ source: "save-ack",
589
+ }));
590
+ }
591
+ else if (result.type === "conflict") {
592
+ if (result.suggestion) {
593
+ if (result.suggestion.updated) {
594
+ persistedSuggestedEditVersions.set(fieldKey, {
595
+ id: result.suggestion.id,
596
+ updated: result.suggestion.updated,
597
+ });
598
+ }
599
+ stateRef.current.setSuggestedEdits((current) => upsertSuggestedEdit(current, result.suggestion, {
600
+ source: "save-ack",
601
+ }));
602
+ if (result.suggestion.status !== "pending" &&
603
+ pendingSuggestedEdits.get(pendingFieldKey)?.id ===
604
+ result.suggestion.id) {
605
+ pendingSuggestedEdits.delete(pendingFieldKey);
606
+ }
607
+ }
608
+ }
609
+ else if (result.type === "error" ||
610
+ result.type === "unauthorized") {
611
+ if (!isSuggestedEditRemoved(saveSnapshot.id)) {
612
+ ui.showErrorToast({
613
+ summary: result.summary || "Failed to save suggestion",
614
+ details: result.details,
615
+ rawDetails: result.rawDetails,
616
+ });
617
+ }
618
+ }
619
+ return result;
620
+ })
621
+ .finally(() => {
622
+ const pendingEdit = pendingSuggestedEdits.get(pendingFieldKey);
623
+ if (pendingEdit?.id === saveSnapshot.id &&
624
+ pendingEdit.newValue === saveSnapshot.newValue) {
625
+ pendingSuggestedEdits.delete(pendingFieldKey);
626
+ }
627
+ });
628
+ }, [ui]);
629
+ const editField = useCallback(async ({ field, value, rawValue, forceMode, refresh = "immediate", saveMode = "debounced", originatingSlotId, }) => {
552
630
  const effectiveMode = forceMode ?? stateRef.current.mode;
553
631
  fieldEditGeneration.current += 1;
632
+ const operationBoundary = fieldOperationBoundary.current;
554
633
  if (effectiveMode === "suggestions" &&
555
634
  (stateRef.current.page || stateRef.current.currentItemDescriptor)) {
556
635
  // Create a unique key for this field to track pending saves
@@ -561,15 +640,24 @@ export function getOperationsContext(state, ui) {
561
640
  fieldId: field.fieldId,
562
641
  });
563
642
  const pendingFieldKey = `${fieldKey}:${stateRef.current.user?.name || "unknown"}`;
564
- const op = await getOrMergeSuggestedEditOp(field, rawValue, value, {
565
- page: stateRef.current.page,
566
- currentItemDescriptor: stateRef.current.currentItemDescriptor,
567
- user: stateRef.current.user,
568
- itemsRepository: itemsRepository,
569
- comments: stateRef.current.comments,
570
- commentsLoaded: stateRef.current.commentsLoaded,
571
- suggestedEdits: stateRef.current.suggestedEdits,
572
- setSuggestedEdits: stateRef.current.setSuggestedEdits,
643
+ let savePromise;
644
+ const op = await enqueueSuggestedEditFieldTask(`prepare:${pendingFieldKey}`, async () => {
645
+ const prepared = await getOrMergeSuggestedEditOp(field, rawValue, value, {
646
+ page: stateRef.current.page,
647
+ currentItemDescriptor: stateRef.current.currentItemDescriptor,
648
+ user: stateRef.current.user,
649
+ itemsRepository: itemsRepository,
650
+ comments: stateRef.current.comments,
651
+ commentsLoaded: stateRef.current.commentsLoaded,
652
+ suggestedEdits: stateRef.current.suggestedEdits,
653
+ setSuggestedEdits: stateRef.current.setSuggestedEdits,
654
+ });
655
+ // Register the save before releasing preparation. A rapid revert
656
+ // can now only queue its delete behind this create/update.
657
+ if (prepared) {
658
+ savePromise = queueSuggestedEditSave(fieldKey, pendingFieldKey, prepared);
659
+ }
660
+ return prepared;
573
661
  });
574
662
  if (op) {
575
663
  logFieldValueDiagnostic("operations", "suggested edit prepared", {
@@ -581,93 +669,6 @@ export function getOperationsContext(state, ui) {
581
669
  oldValue: summarizeFieldValue(op.oldValue),
582
670
  newValue: summarizeFieldValue(op.newValue),
583
671
  });
584
- // Keep local suggestion state in step with typing immediately, then
585
- // serialize persistence behind any in-flight save for this field.
586
- // Create and track the save promise. useDebouncedCallback can return
587
- // undefined before the trailing call runs, so this path uses a real
588
- // promise that resolves after the delayed save completes.
589
- const saveSnapshot = { ...op };
590
- const expectedNewValue = saveSnapshot.newValue;
591
- const savePromise = enqueueSuggestedEditFieldTask(fieldKey, () => {
592
- // Input handlers may prepare several snapshots before React state
593
- // receives the first save acknowledgement. Read the durable
594
- // server-issued version at execution time so every queued save is
595
- // based on the immediately preceding one.
596
- const persistedVersion = persistedSuggestedEditVersions.get(fieldKey);
597
- const snapshotForSave = persistedVersion?.id === saveSnapshot.id
598
- ? { ...saveSnapshot, updated: persistedVersion.updated }
599
- : saveSnapshot;
600
- return createDelayedSuggestedEditSave(snapshotForSave, persistedVersion?.id === saveSnapshot.id
601
- ? persistedVersion.updated
602
- : undefined);
603
- })
604
- .then((result) => {
605
- // Update the suggested edits state with the result from the server
606
- if (result.type === "success" && result.data) {
607
- const updatedEdit = result.data;
608
- if (updatedEdit.updated) {
609
- persistedSuggestedEditVersions.set(fieldKey, {
610
- id: updatedEdit.id,
611
- updated: updatedEdit.updated,
612
- });
613
- }
614
- if (isSuggestedEditRemoved(updatedEdit.id)) {
615
- // Reverted to a no-op while this save was in flight; make sure
616
- // it stays gone on the server and do not re-add it locally.
617
- void deleteSuggestedEdit(updatedEdit).then((deleteResult) => {
618
- if (deleteResult.type !== "success") {
619
- console.warn("Failed to delete reverted suggestion after save:", deleteResult.summary, deleteResult.details);
620
- }
621
- }, () => undefined);
622
- return result;
623
- }
624
- stateRef.current.setSuggestedEdits((current) => upsertSuggestedEdit(current, updatedEdit, {
625
- expectedNewValue,
626
- source: "save-ack",
627
- }));
628
- }
629
- else if (result.type === "conflict") {
630
- if (result.suggestion) {
631
- if (result.suggestion.updated) {
632
- persistedSuggestedEditVersions.set(fieldKey, {
633
- id: result.suggestion.id,
634
- updated: result.suggestion.updated,
635
- });
636
- }
637
- stateRef.current.setSuggestedEdits((current) => upsertSuggestedEdit(current, result.suggestion, {
638
- source: "save-ack",
639
- }));
640
- if (result.suggestion.status !== "pending" &&
641
- pendingSuggestedEdits.get(pendingFieldKey)?.id ===
642
- result.suggestion.id) {
643
- pendingSuggestedEdits.delete(pendingFieldKey);
644
- }
645
- }
646
- }
647
- else if (result.type === "error" ||
648
- result.type === "unauthorized") {
649
- // Show error toast for failed suggested edit save — unless the
650
- // suggestion was reverted while the request was in flight, in
651
- // which case there is nothing for the user to see or retry.
652
- if (!isSuggestedEditRemoved(saveSnapshot.id)) {
653
- ui.showErrorToast({
654
- summary: result.summary || "Failed to save suggestion",
655
- details: result.details,
656
- rawDetails: result.rawDetails,
657
- });
658
- }
659
- }
660
- return result;
661
- })
662
- .finally(() => {
663
- // Also remove from pending edits once this exact value is
664
- // persisted. If the user typed again, keep the newer draft.
665
- const pendingEdit = pendingSuggestedEdits.get(pendingFieldKey);
666
- if (pendingEdit?.id === saveSnapshot.id &&
667
- pendingEdit.newValue === saveSnapshot.newValue) {
668
- pendingSuggestedEdits.delete(pendingFieldKey);
669
- }
670
- });
671
672
  await savePromise;
672
673
  }
673
674
  logFieldValueDiagnostic("operations", "suggestions edit complete", {
@@ -682,11 +683,12 @@ export function getOperationsContext(state, ui) {
682
683
  name: "unknown",
683
684
  ai: false,
684
685
  agentId: undefined,
685
- }, true, value, rawValue, fieldModificationStore);
686
- if (refresh === "immediate") {
686
+ }, true, value, rawValue, fieldModificationStore, refresh !== "none");
687
+ if (refresh === "immediate" || saveMode === "immediate") {
687
688
  logFieldValueDiagnostic("operations", "saving immediately", {
688
689
  field: summarizeFieldDescriptor(field),
689
690
  saveSequence,
691
+ refresh,
690
692
  });
691
693
  return enqueueFieldSave(field, () => editFieldImmediate({
692
694
  field,
@@ -695,6 +697,7 @@ export function getOperationsContext(state, ui) {
695
697
  refresh,
696
698
  saveSequence,
697
699
  fieldModificationStore,
700
+ operationBoundary,
698
701
  }));
699
702
  }
700
703
  const isNewField = lastEditField.current?.fieldId !== field.fieldId ||
@@ -713,44 +716,44 @@ export function getOperationsContext(state, ui) {
713
716
  refresh,
714
717
  saveSequence,
715
718
  fieldModificationStore,
719
+ operationBoundary,
716
720
  });
717
721
  }, [
718
722
  itemsRepository,
719
723
  getOrMergeSuggestedEditOp,
724
+ queueSuggestedEditSave,
720
725
  resolveOriginatingFieldModificationStore,
721
726
  enqueueFieldSave,
722
727
  ]);
723
- const editFieldImmediate = useCallback(async ({ field, value, rawValue, refresh = "immediate", saveSequence, fieldModificationStore, }) => {
728
+ const editFieldImmediate = useCallback(async ({ field, value, rawValue, refresh = "immediate", saveSequence, fieldModificationStore, operationBoundary, }) => {
724
729
  const val = rawValue !== undefined ? rawValue : value;
725
730
  if (val === undefined)
726
731
  return;
727
- const op = await getEditOP(field, rawValue, value);
728
- // Fix: Handle getEditOP returning undefined (item/field not found)
729
- // Clear dirty state so the field doesn't get stuck in "saving" state
732
+ const op = await getEditOP(field, rawValue, value, operationBoundary);
733
+ // An operation that could not be constructed was not saved. Keep the
734
+ // modification dirty instead of publishing a false successful state.
730
735
  if (!op) {
731
- console.warn("[editFieldImmediate] Could not create edit operation for field:", field.fieldId);
736
+ const error = new Error(`Could not create edit operation for field ${field.fieldId}`);
737
+ console.warn("[editFieldImmediate]", error.message);
732
738
  logFieldValueDiagnostic("operations", "editFieldImmediate no op", {
733
739
  field: summarizeFieldDescriptor(field),
734
740
  value: summarizeFieldValue(value),
735
741
  rawValue: summarizeFieldValue(rawValue),
736
742
  saveSequence,
737
743
  });
738
- itemsRepository.clearDirtyState(field, fieldModificationStore);
739
- return;
744
+ throw error;
740
745
  }
741
746
  try {
742
747
  const result = await executeOp(op, { refresh });
743
748
  // Fix: Check if the operation actually succeeded
744
749
  // If result is undefined/null, the operation likely failed
745
750
  if (!result) {
746
- console.warn("[editFieldImmediate] Edit operation returned no result, clearing dirty state");
747
751
  logFieldValueDiagnostic("operations", "editFieldImmediate no result", {
748
752
  field: summarizeFieldDescriptor(field),
749
753
  opId: op.id,
750
754
  saveSequence,
751
755
  });
752
- itemsRepository.clearDirtyState(field, fieldModificationStore);
753
- return;
756
+ throw new Error(`Edit operation returned no result for field ${field.fieldId}`);
754
757
  }
755
758
  // Don't refresh here - the websocket handler will trigger a refresh when
756
759
  // it receives the item-changed notification from the server. This avoids
@@ -759,15 +762,23 @@ export function getOperationsContext(state, ui) {
759
762
  isWorkflowMetadataField(op.fieldId, op.fieldName)) {
760
763
  refreshWorkboxAfterWorkflowChange();
761
764
  }
762
- if (result.executionStatus !== "failed") {
763
- latestSavedFieldEditOperationId.current = op.id;
764
- completedFieldSaveGeneration.current += 1;
765
+ if (result.executionStatus === "failed") {
766
+ throw new Error(`Edit operation failed for field ${field.fieldId}`);
765
767
  }
768
+ latestSavedFieldEditOperationId.current = op.id;
769
+ completedFieldSaveGeneration.current += 1;
766
770
  itemsRepository.onFieldSaved(field, val, saveSequence || 0, fieldModificationStore);
771
+ if (refresh !== "none") {
772
+ // The edit response can precede rendering-host cache invalidation.
773
+ // The immediate refresh remains the fast path; this quiet follow-up
774
+ // reconciles the canvas when that first render observed stale data
775
+ // or the corresponding websocket notification was missed.
776
+ stateRef.current.requestRefresh("waitForQuietPeriod");
777
+ }
767
778
  }
768
779
  catch (error) {
769
- // Fix: Handle network errors and other exceptions
770
- // Clear the dirty state to prevent the field from getting stuck in "saving" state
780
+ // Network and execution errors leave the modification dirty: clearing
781
+ // it here makes consumers believe persistence completed successfully.
771
782
  console.error("[editFieldImmediate] Error saving field:", error);
772
783
  logFieldValueDiagnostic("operations", "editFieldImmediate failed", {
773
784
  field: summarizeFieldDescriptor(field),
@@ -776,10 +787,10 @@ export function getOperationsContext(state, ui) {
776
787
  saveSequence,
777
788
  error: error instanceof Error ? error.message : String(error),
778
789
  });
779
- itemsRepository.clearDirtyState(field, fieldModificationStore);
790
+ throw error;
780
791
  }
781
792
  }, [itemsRepository, executeOp, refreshWorkboxAfterWorkflowChange]);
782
- const executeEditFieldDebounced = useDebouncedCallback(async ({ field, value, rawValue, refresh, saveSequence, fieldModificationStore, }) => {
793
+ const executeEditFieldDebounced = useDebouncedCallback(async ({ field, value, rawValue, refresh, saveSequence, fieldModificationStore, operationBoundary, }) => {
783
794
  // Fix: Use the saveSequence passed from editField instead of calling
784
795
  // updateFieldValue again. This prevents the race condition where the
785
796
  // field gets marked dirty twice with different sequences.
@@ -790,6 +801,7 @@ export function getOperationsContext(state, ui) {
790
801
  refresh,
791
802
  saveSequence,
792
803
  fieldModificationStore,
804
+ operationBoundary,
793
805
  }));
794
806
  }, stateRef.current.configuration.debounceFieldEditsInterval * 2, { trailing: true });
795
807
  const flushPendingFieldEdits = useCallback(async () => {
@@ -806,8 +818,20 @@ export function getOperationsContext(state, ui) {
806
818
  ? latestSavedFieldEditOperationId.current
807
819
  : undefined;
808
820
  }, [executeEditFieldDebounced]);
821
+ const prepareForModeChange = useCallback(async () => {
822
+ const lastField = lastEditField.current;
823
+ await flushPendingFieldEdits();
824
+ if (!lastField)
825
+ return;
826
+ // A mode switch is a consistency boundary. Refresh the field that was just
827
+ // persisted so suggestions are based on the committed Sitecore value, not
828
+ // on an older cache response that raced the save acknowledgement.
829
+ await itemsRepository.refreshItems([lastField.item], {
830
+ fields: [lastField.fieldId],
831
+ });
832
+ }, [flushPendingFieldEdits, itemsRepository]);
809
833
  // Note: We intentionally keep a single save debounce window here.
810
- async function getEditOP(field, rawValue, value) {
834
+ async function getEditOP(field, rawValue, value, operationBoundary) {
811
835
  const item = await itemsRepository.getItem(field.item);
812
836
  if (!item)
813
837
  return;
@@ -836,7 +860,8 @@ export function getOperationsContext(state, ui) {
836
860
  op.itemId !== item.id ||
837
861
  op.fieldId !== field.fieldId ||
838
862
  op.mainItem.language !== item.language ||
839
- op.mainItem.version !== item.version) {
863
+ op.mainItem.version !== item.version ||
864
+ lastOpBoundary.current !== operationBoundary) {
840
865
  // Use state.page's descriptor only if its language AND version match the field's
841
866
  // to prevent race conditions when switching languages or versions quickly
842
867
  const page = stateRef.current.page;
@@ -868,6 +893,7 @@ export function getOperationsContext(state, ui) {
868
893
  if (firstEditInheritedComponent)
869
894
  op.mainItem.language = stateRef.current.page.item.language;
870
895
  lastOp.current = op;
896
+ lastOpBoundary.current = operationBoundary;
871
897
  return op;
872
898
  }
873
899
  const executeUndoOperation = useCallback(async (ops) => {
@@ -876,7 +902,10 @@ export function getOperationsContext(state, ui) {
876
902
  // This ensures the UI only changes when the operation truly completes
877
903
  const operationWasRemoved = isMissingEditOperationResult(result);
878
904
  if (!operationWasRemoved) {
879
- handleResult(result, { refresh: "immediate" });
905
+ // Reconcile the affected repository items before starting the canvas
906
+ // request below. Refreshing here would let a busy rendering host read
907
+ // the pre-undo snapshot and repaint stale content over the new value.
908
+ handleResult(result, { refresh: "none" });
880
909
  }
881
910
  if (result.type === "success") {
882
911
  for (const executedOp of result.data) {
@@ -902,7 +931,6 @@ export function getOperationsContext(state, ui) {
902
931
  // The targeted refresh via itemsRepository.refreshItems is not sufficient because:
903
932
  // 1. When undoing add-component, the component no longer exists to refresh
904
933
  // 2. When undoing remove-component, the component wasn't in cache
905
- const hasDeleteOperations = true; // Always refresh after undo
906
934
  const itemsToRefresh = result.data
907
935
  .map((x) => {
908
936
  if (x.type === "edit-field")
@@ -921,8 +949,8 @@ export function getOperationsContext(state, ui) {
921
949
  .flat()
922
950
  .filter((x) => x != null);
923
951
  await itemsRepository.refreshItems(itemsToRefresh);
924
- // handleResult already triggers the single immediate page refresh.
925
- // Avoid a second request here, which can flood a busy rendering host.
952
+ markNextBridgeRefreshAuthoritative();
953
+ stateRef.current.requestRefresh("immediate");
926
954
  return true;
927
955
  }
928
956
  else {
@@ -1228,7 +1256,7 @@ export function getOperationsContext(state, ui) {
1228
1256
  // which merges server responses + websocket updates while enforcing scope filtering.
1229
1257
  const operationWasRemoved = isMissingEditOperationResult(result);
1230
1258
  if (!operationWasRemoved) {
1231
- handleResult(result, { refresh: "immediate" });
1259
+ handleResult(result, { refresh: "none" });
1232
1260
  }
1233
1261
  if (result.type === "success") {
1234
1262
  for (const executedOp of result.data) {
@@ -1264,6 +1292,8 @@ export function getOperationsContext(state, ui) {
1264
1292
  })
1265
1293
  .filter((x) => x != null);
1266
1294
  await itemsRepository.refreshItems(itemsToRefresh);
1295
+ markNextBridgeRefreshAuthoritative();
1296
+ stateRef.current.requestRefresh("immediate");
1267
1297
  return true;
1268
1298
  }
1269
1299
  else {
@@ -1558,6 +1588,24 @@ export function getOperationsContext(state, ui) {
1558
1588
  return true;
1559
1589
  }, [itemsRepository, executeOp, ui]);
1560
1590
  const moveItems = useCallback(async (items, target, index) => {
1591
+ // Capture source parents before the background operation starts. The
1592
+ // terminal payload does not need to carry this UI-specific context, and
1593
+ // after completion the items already belong to the destination.
1594
+ let sourceParents = [];
1595
+ try {
1596
+ const sourceItems = await itemsRepository.getItems(items);
1597
+ sourceParents = sourceItems
1598
+ .filter((item) => !!item.parentId)
1599
+ .map((item) => ({
1600
+ id: item.parentId,
1601
+ language: item.language,
1602
+ version: 0,
1603
+ database: item.database,
1604
+ }));
1605
+ }
1606
+ catch (error) {
1607
+ console.warn("Could not capture move source parents:", error);
1608
+ }
1561
1609
  // Use generic edit endpoint for long-running operation support
1562
1610
  const op = {
1563
1611
  type: "move-items",
@@ -1569,9 +1617,29 @@ export function getOperationsContext(state, ui) {
1569
1617
  id: uuid(),
1570
1618
  title: `Moving ${items.length} item(s)...`,
1571
1619
  };
1572
- const result = await executeOp(op);
1573
- return result?.executionStatus !== "failed";
1574
- }, [executeOp]);
1620
+ let result = (await executeOp(op));
1621
+ if (copyItemsNeedsTerminalCompletion(result)) {
1622
+ try {
1623
+ const terminal = await waitForEditOperationTerminal(result.id, 120_000);
1624
+ result = { ...result, ...terminal };
1625
+ }
1626
+ catch {
1627
+ return false;
1628
+ }
1629
+ }
1630
+ if (result.executionStatus === "failed" || result.errorMessage) {
1631
+ return false;
1632
+ }
1633
+ const parentsById = new Map();
1634
+ for (const parent of [...sourceParents, { ...target, version: 0 }]) {
1635
+ const key = cleanId(parent.id) ?? parent.id;
1636
+ parentsById.set(key, parent);
1637
+ }
1638
+ await itemsRepository.refreshItems(Array.from(parentsById.values()), {
1639
+ children: true,
1640
+ });
1641
+ return true;
1642
+ }, [executeOp, itemsRepository]);
1575
1643
  const createItem = useCallback(async (parent, templateId, name) => {
1576
1644
  const op = {
1577
1645
  type: "create-item",
@@ -1736,24 +1804,20 @@ export function getOperationsContext(state, ui) {
1736
1804
  }));
1737
1805
  return result;
1738
1806
  }, [executeOp]);
1739
- const onFieldBlur = useCallback(() => {
1807
+ const onFieldBlur = useCallback(async () => {
1740
1808
  const editGenerationAtBlur = fieldEditGeneration.current;
1741
- const pendingSave = executeEditFieldDebounced.isPending()
1742
- ? executeEditFieldDebounced.flush()
1743
- : undefined;
1744
- if (pendingSave) {
1745
- void Promise.resolve(pendingSave).finally(() => {
1746
- // Preserve a newer edit that started while the blurred field was saving.
1747
- if (fieldEditGeneration.current === editGenerationAtBlur) {
1748
- lastOp.current = undefined;
1749
- }
1750
- });
1751
- return;
1752
- }
1753
- // Reset the last operation to force creation of a new operation
1754
- // when the user returns to edit any field after losing focus.
1755
- lastOp.current = undefined;
1756
- }, [executeEditFieldDebounced]);
1809
+ // Advance synchronously. The DOM blur event cannot await this callback, so
1810
+ // doing it after the save would allow an edit started during that save to
1811
+ // coalesce into the operation that belongs to the field being left.
1812
+ fieldOperationBoundary.current += 1;
1813
+ await flushPendingFieldEdits();
1814
+ // Preserve a newer edit that started while the blurred field was saving.
1815
+ // Otherwise force a new operation when the user returns to this field.
1816
+ if (fieldEditGeneration.current === editGenerationAtBlur) {
1817
+ lastOp.current = undefined;
1818
+ lastOpBoundary.current = undefined;
1819
+ }
1820
+ }, [flushPendingFieldEdits]);
1757
1821
  // Extract state values to avoid object reference changes
1758
1822
  const mode = stateRef.current.mode;
1759
1823
  const page = stateRef.current.page;
@@ -1783,6 +1847,7 @@ export function getOperationsContext(state, ui) {
1783
1847
  cloneItems,
1784
1848
  duplicateItem,
1785
1849
  onFieldBlur,
1850
+ prepareForModeChange,
1786
1851
  }), [
1787
1852
  addComponent,
1788
1853
  ensureLock,
@@ -1810,6 +1875,7 @@ export function getOperationsContext(state, ui) {
1810
1875
  cloneItems,
1811
1876
  duplicateItem,
1812
1877
  onFieldBlur,
1878
+ prepareForModeChange,
1813
1879
  ]);
1814
1880
  // Cleanup function to flush pending debounced operations on unmount
1815
1881
  // This prevents fields from getting stuck in "saving" state when navigating away
@@ -1924,10 +1990,10 @@ async function getOrMergeSuggestedEditOp(field, rawValue, value, state) {
1924
1990
  // server cascade-deletes a suggestion's comments along with it).
1925
1991
  state.commentsLoaded === true &&
1926
1992
  shouldRemoveNoOpSuggestion(edit.oldValue, nextValue, () => getSuggestionCommentCount(edit, state)) &&
1927
- // Only auto-remove when the typed value is also a no-op against the
1928
- // field's live value: if the field changed underneath the suggestion, the
1929
- // typed content still differs from the page and must stay recorded.
1930
- !hasSuggestionContentChange(fieldItem.rawValue, nextValue);
1993
+ // A live value equal to the draft is an optimistic editor echo, not an
1994
+ // external field change. Preserve the suggestion only when the live field
1995
+ // contains a third value that differs from both its baseline and draft.
1996
+ !hasLiveFieldDivergedFromSuggestion(edit.oldValue, edit.newValue, fieldItem.rawValue);
1931
1997
  // First check if there's a pending suggested edit for this field. A draft
1932
1998
  // whose suggestion was already reverted must not be revived — a new edit
1933
1999
  // mints a fresh suggestion instead.