@alpaca-editor/core 1.0.4042 → 1.0.4044

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 (49) hide show
  1. package/dist/components/ui/button.d.ts +1 -1
  2. package/dist/editor/ContentTree.js +5 -1
  3. package/dist/editor/ContentTree.js.map +1 -1
  4. package/dist/editor/ScrollingContentTree.js +4 -1
  5. package/dist/editor/ScrollingContentTree.js.map +1 -1
  6. package/dist/editor/ai/AiTerminal.js +1 -46
  7. package/dist/editor/ai/AiTerminal.js.map +1 -1
  8. package/dist/editor/client/EditorClient.js +127 -37
  9. package/dist/editor/client/EditorClient.js.map +1 -1
  10. package/dist/editor/client/editContext.d.ts +1 -0
  11. package/dist/editor/client/editContext.js.map +1 -1
  12. package/dist/editor/client/operations.d.ts +3 -0
  13. package/dist/editor/client/operations.js +30 -1
  14. package/dist/editor/client/operations.js.map +1 -1
  15. package/dist/editor/commands/componentCommands.js +6 -0
  16. package/dist/editor/commands/componentCommands.js.map +1 -1
  17. package/dist/editor/media-selector/Thumbnails.js +1 -1
  18. package/dist/editor/media-selector/Thumbnails.js.map +1 -1
  19. package/dist/editor/page-editor-chrome/FrameMenu.js +1 -1
  20. package/dist/editor/page-editor-chrome/FrameMenu.js.map +1 -1
  21. package/dist/editor/page-editor-chrome/InlineEditor.js +30 -17
  22. package/dist/editor/page-editor-chrome/InlineEditor.js.map +1 -1
  23. package/dist/editor/reviews/SuggestedEdit.js +18 -2
  24. package/dist/editor/reviews/SuggestedEdit.js.map +1 -1
  25. package/dist/editor/reviews/SuggestionDisplayPopover.js +18 -3
  26. package/dist/editor/reviews/SuggestionDisplayPopover.js.map +1 -1
  27. package/dist/editor/services/agentService.js +1 -40
  28. package/dist/editor/services/agentService.js.map +1 -1
  29. package/dist/editor/ui/PerfectTree.js +2 -2
  30. package/dist/editor/ui/PerfectTree.js.map +1 -1
  31. package/dist/revision.d.ts +2 -2
  32. package/dist/revision.js +2 -2
  33. package/dist/styles.css +2 -5
  34. package/package.json +1 -1
  35. package/src/editor/ContentTree.tsx +5 -1
  36. package/src/editor/ScrollingContentTree.tsx +5 -1
  37. package/src/editor/ai/AiTerminal.tsx +2 -96
  38. package/src/editor/client/EditorClient.tsx +159 -52
  39. package/src/editor/client/editContext.ts +2 -6
  40. package/src/editor/client/operations.ts +46 -2
  41. package/src/editor/commands/componentCommands.tsx +4 -0
  42. package/src/editor/media-selector/Thumbnails.tsx +4 -1
  43. package/src/editor/page-editor-chrome/FrameMenu.tsx +1 -0
  44. package/src/editor/page-editor-chrome/InlineEditor.tsx +41 -19
  45. package/src/editor/reviews/SuggestedEdit.tsx +23 -2
  46. package/src/editor/reviews/SuggestionDisplayPopover.tsx +18 -3
  47. package/src/editor/services/agentService.ts +1 -76
  48. package/src/editor/ui/PerfectTree.tsx +11 -9
  49. package/src/revision.ts +2 -2
@@ -26,8 +26,6 @@ import {
26
26
  import { fieldModificationStore } from "./fieldModificationStore";
27
27
 
28
28
  import type { OpenDialog } from "./editContext";
29
- import type { AiTerminalOptions } from "../ai/AiTerminal";
30
-
31
29
  import { EditorConfiguration, MenuItem } from "../../config/types";
32
30
  import { useRouter, useSearchParams, usePathname } from "next/navigation";
33
31
  import { findComponent, getComponentById } from "../componentTreeHelper";
@@ -256,7 +254,16 @@ export function EditorClient({
256
254
  });
257
255
  }, []);
258
256
 
259
- const [activeSessions, setActiveSessions] = useState<EditSession[]>([]);
257
+ const [activeSessions, setActiveSessions] = useState<EditSession[]>(() => {
258
+ try {
259
+ const existing = (globalThis as any).__alpacaActiveSessions as
260
+ | EditSession[]
261
+ | undefined;
262
+ return Array.isArray(existing) ? existing : [];
263
+ } catch {
264
+ return [];
265
+ }
266
+ });
260
267
 
261
268
  if (typeof window !== "undefined")
262
269
  sessionStorage?.setItem("sessionId", sessionId);
@@ -413,6 +420,7 @@ export function EditorClient({
413
420
  });
414
421
 
415
422
  const socketMessageListeners = useRef<Set<(data: any) => void>>(new Set());
423
+ const socketInstanceRef = useRef<WebSocket | null>(null);
416
424
 
417
425
  const addSocketMessageListener = useCallback(
418
426
  (callback: (message: { type: string; payload: any }) => void) => {
@@ -531,14 +539,15 @@ export function EditorClient({
531
539
  }
532
540
 
533
541
  if (message.type === "active-sessions") {
534
- setActiveSessions(() => {
542
+ setActiveSessions((prev) => {
535
543
  // Ensure payload is an array and contains valid session objects
536
544
  if (!Array.isArray(message.payload)) {
537
545
  console.warn(
538
546
  "❌ Active sessions payload is not an array:",
539
547
  message.payload,
540
548
  );
541
- return [];
549
+ (globalThis as any).__alpacaActiveSessions = prev;
550
+ return prev; // keep previous instead of clearing to avoid losing user during HMR blips
542
551
  }
543
552
 
544
553
  // Filter out any invalid session objects
@@ -560,8 +569,41 @@ export function EditorClient({
560
569
  );
561
570
  }
562
571
 
572
+ // If server reports empty list, keep previous to avoid transient blanking during HMR
573
+ if (validSessions.length === 0 && prev.length > 0) {
574
+ console.warn(
575
+ "⚠️ Received empty active sessions; preserving previous list to avoid losing state",
576
+ );
577
+ (globalThis as any).__alpacaActiveSessions = prev;
578
+ return prev;
579
+ }
580
+
581
+ (globalThis as any).__alpacaActiveSessions = validSessions;
563
582
  return validSessions;
564
583
  });
584
+
585
+ // Detect if the current session is missing from the list and self-heal
586
+ try {
587
+ const payload = Array.isArray(message.payload)
588
+ ? (message.payload as any[])
589
+ : [];
590
+ const hasMySession = payload.some(
591
+ (s) => s && s.sessionId === sessionId,
592
+ );
593
+ if (!hasMySession) {
594
+ console.warn(
595
+ "⚠️ Current session missing from active sessions. Re-sending client-info to recover...",
596
+ );
597
+ setTimeout(() => {
598
+ sendClientInfo();
599
+ }, 300);
600
+ }
601
+ } catch (e) {
602
+ console.warn(
603
+ "Failed to verify active sessions for current session!",
604
+ e,
605
+ );
606
+ }
565
607
  }
566
608
 
567
609
  if (message.type === "item-deleted") {
@@ -761,10 +803,61 @@ export function EditorClient({
761
803
  );
762
804
 
763
805
  const user = useMemo(
764
- () => activeSessions.find((x) => x.sessionId === sessionId)?.user,
765
- [activeSessions, sessionId],
806
+ () =>
807
+ activeSessions.find((x) => x.sessionId === sessionId)?.user ||
808
+ userInfo.user,
809
+ [activeSessions, sessionId, userInfo.user],
766
810
  );
767
811
 
812
+ // Self-heal if our session disappears (e.g., after HMR)
813
+ const missingSessionRecoveryTimerRef = useRef<NodeJS.Timeout | null>(null);
814
+ const missingSessionRecoveryAttemptsRef = useRef<number>(0);
815
+ useEffect(() => {
816
+ const hasMySession = activeSessions.some((s) => s.sessionId === sessionId);
817
+ if (hasMySession) {
818
+ // Reset recovery state when we see ourselves again
819
+ if (missingSessionRecoveryTimerRef.current) {
820
+ clearTimeout(missingSessionRecoveryTimerRef.current);
821
+ missingSessionRecoveryTimerRef.current = null;
822
+ }
823
+ missingSessionRecoveryAttemptsRef.current = 0;
824
+ return;
825
+ }
826
+
827
+ // Only run one recovery timer at a time
828
+ if (missingSessionRecoveryTimerRef.current) return;
829
+
830
+ const attempt = missingSessionRecoveryAttemptsRef.current + 1;
831
+ const delay = Math.min(250 * Math.pow(2, attempt - 1), 2000);
832
+
833
+ console.warn(
834
+ `⚠️ Current session not present in active sessions. Recovery attempt ${attempt} in ${delay}ms...`,
835
+ );
836
+
837
+ missingSessionRecoveryTimerRef.current = setTimeout(() => {
838
+ missingSessionRecoveryTimerRef.current = null;
839
+ missingSessionRecoveryAttemptsRef.current = attempt;
840
+
841
+ if (attempt <= 3) {
842
+ sendClientInfo();
843
+ } else {
844
+ // Force a reconnect to refresh presence after several failed nudges
845
+ try {
846
+ socketInstanceRef.current?.close(4000, "recover-presence");
847
+ } catch {
848
+ // Ignore errors while attempting to recover presence
849
+ }
850
+ }
851
+ }, delay);
852
+
853
+ return () => {
854
+ if (missingSessionRecoveryTimerRef.current) {
855
+ clearTimeout(missingSessionRecoveryTimerRef.current);
856
+ missingSessionRecoveryTimerRef.current = null;
857
+ }
858
+ };
859
+ }, [activeSessions, sessionId]);
860
+
768
861
  useEffect(() => {
769
862
  if (typeof window === "undefined") return;
770
863
 
@@ -919,57 +1012,64 @@ export function EditorClient({
919
1012
  let reconnectAttempts = 0;
920
1013
 
921
1014
  const connectWebSocket = () => {
922
- let socket: WebSocket = (globalThis as any).editorSocket;
1015
+ let socket: WebSocket | undefined = (globalThis as any).editorSocket;
1016
+
1017
+ const needsNewSocket =
1018
+ !socket ||
1019
+ socket.readyState === WebSocket.CLOSING ||
1020
+ socket.readyState === WebSocket.CLOSED;
1021
+
1022
+ if (needsNewSocket) {
1023
+ socket = connectSocket(sessionId);
1024
+
1025
+ // Connection opened
1026
+ socket.addEventListener("open", () => {
1027
+ console.log("Connected!");
1028
+ reconnectAttempts = 0; // Reset attempts on successful connection
1029
+ sendClientInfo();
1030
+ requestQuota();
1031
+ //TODO: Load clients
1032
+ });
923
1033
 
924
- if (
925
- socket &&
926
- (socket.readyState === WebSocket.OPEN ||
927
- socket.readyState === WebSocket.CONNECTING)
928
- )
929
- return;
1034
+ // Handle connection close
1035
+ socket.addEventListener("close", (event) => {
1036
+ // WebSocket connection closed
930
1037
 
931
- socket = connectSocket(sessionId);
1038
+ // Only attempt to reconnect if it wasn't a clean close
1039
+ if (event.code !== 1000) {
1040
+ // Start with 1 second, increase exponentially up to 30 seconds
1041
+ const delay = Math.min(
1042
+ 1000 * Math.pow(2, Math.min(reconnectAttempts, 5)),
1043
+ 30000,
1044
+ );
1045
+ // Attempting to reconnect with backoff
932
1046
 
933
- // Connection opened
934
- socket.addEventListener("open", () => {
935
- console.log("Connected!");
936
- reconnectAttempts = 0; // Reset attempts on successful connection
937
- sendClientInfo();
938
- requestQuota();
939
- //TODO: Load clients
940
- });
1047
+ reconnectTimeout = setTimeout(() => {
1048
+ reconnectAttempts++;
1049
+ connectWebSocket();
1050
+ }, delay);
1051
+ }
1052
+ });
941
1053
 
942
- // Listen for messages
943
- socket.addEventListener("message", messageHandler);
1054
+ // Handle connection errors
1055
+ socket.addEventListener("error", (error) => {
1056
+ console.error("WebSocket error:", error);
1057
+ });
944
1058
 
945
- // Handle connection close
946
- socket.addEventListener("close", (event) => {
947
- console.log("WebSocket connection closed:", event.code, event.reason);
1059
+ (globalThis as any).editorSocket = socket;
1060
+ }
948
1061
 
949
- // Only attempt to reconnect if it wasn't a clean close
950
- if (event.code !== 1000) {
951
- // Start with 1 second, increase exponentially up to 30 seconds
952
- const delay = Math.min(
953
- 1000 * Math.pow(2, Math.min(reconnectAttempts, 5)),
954
- 30000,
955
- );
956
- console.log(
957
- `Attempting to reconnect in ${delay}ms... (attempt ${reconnectAttempts + 1})`,
958
- );
1062
+ // Always ensure this instance is listening to messages
1063
+ if (socket) {
1064
+ socket.addEventListener("message", messageHandler);
1065
+ socketInstanceRef.current = socket;
959
1066
 
960
- reconnectTimeout = setTimeout(() => {
961
- reconnectAttempts++;
962
- connectWebSocket();
963
- }, delay);
1067
+ // If we attached to an already-open socket, resend client info to refresh presence
1068
+ if (socket.readyState === WebSocket.OPEN) {
1069
+ sendClientInfo();
1070
+ requestQuota();
964
1071
  }
965
- });
966
-
967
- // Handle connection errors
968
- socket.addEventListener("error", (error) => {
969
- console.error("WebSocket error:", error);
970
- });
971
-
972
- (globalThis as any).editorSocket = socket;
1072
+ }
973
1073
  };
974
1074
 
975
1075
  connectWebSocket();
@@ -979,6 +1079,13 @@ export function EditorClient({
979
1079
  if (reconnectTimeout) {
980
1080
  clearTimeout(reconnectTimeout);
981
1081
  }
1082
+ if (socketInstanceRef.current) {
1083
+ socketInstanceRef.current.removeEventListener(
1084
+ "message",
1085
+ messageHandler,
1086
+ );
1087
+ socketInstanceRef.current = null;
1088
+ }
982
1089
  };
983
1090
  }, []);
984
1091
 
@@ -1904,7 +2011,7 @@ export function EditorClient({
1904
2011
  viewName,
1905
2012
  ]);
1906
2013
 
1907
- const editContext = useMemo(() => {
2014
+ const editContext = useMemo<EditContextType>(() => {
1908
2015
  // console.log('🔄 EditContext useMemo is being recalculated');
1909
2016
  const context = {
1910
2017
  operations: operationsContext.ops,
@@ -2330,7 +2437,7 @@ export function EditorClient({
2330
2437
  loadFavorites,
2331
2438
  };
2332
2439
 
2333
- return context;
2440
+ return context as unknown as EditContextType;
2334
2441
  }, [
2335
2442
  operations,
2336
2443
  itemsRepository,
@@ -52,12 +52,7 @@ import {
52
52
  import { ItemsRepository } from "./itemsRepository";
53
53
  import { MediaSelectorMode } from "../media-selector/MediaSelector";
54
54
  import { ComponentCommand } from "../commands/componentCommands";
55
- import { AiTerminalOptions } from "../ai/AiTerminal";
56
- import {
57
- Wizard,
58
- WizardData,
59
- WizardPageModel,
60
- } from "../../page-wizard/PageWizard";
55
+
61
56
  export type DragObject = {
62
57
  type: "template" | "component" | "link-component" | "items";
63
58
  typeId: string;
@@ -140,6 +135,7 @@ export type EditContextType = {
140
135
  };
141
136
  comments: Comment[];
142
137
  suggestedEdits: SuggestedEdit[];
138
+ setSuggestedEdits: React.Dispatch<React.SetStateAction<SuggestedEdit[]>>;
143
139
  loadComments: () => void;
144
140
  setComments: React.Dispatch<React.SetStateAction<Comment[]>>;
145
141
  selectedComment: Comment | undefined;
@@ -9,6 +9,7 @@ import {
9
9
  SuggestedEdit,
10
10
  User,
11
11
  } from "../../types";
12
+
12
13
  import {
13
14
  FieldDescriptor,
14
15
  FullItem,
@@ -45,6 +46,23 @@ import { EditorMode } from "./editContext";
45
46
  // Track pending suggested edit save requests to prevent race conditions
46
47
  const pendingSuggestedEditSaves = new Map<string, Promise<any>>();
47
48
 
49
+ // Track pending suggested edits to prevent creating duplicates before server response
50
+ const pendingSuggestedEdits = new Map<string, SuggestedEdit>();
51
+
52
+ // Helper function to clean up pending operations for a deleted suggested edit
53
+ export function cleanupPendingSuggestedEditOperations(
54
+ suggestedEdit: SuggestedEdit,
55
+ user?: { name: string },
56
+ ) {
57
+ const userKey = user?.name || "unknown";
58
+ const pendingFieldKey = `${suggestedEdit.itemId}:${suggestedEdit.mainItemLanguage}:${suggestedEdit.mainItemVersion}:${suggestedEdit.fieldId}:${userKey}`;
59
+ const saveKey = `${suggestedEdit.itemId}:${suggestedEdit.mainItemLanguage}:${suggestedEdit.mainItemVersion}:${suggestedEdit.fieldId}`;
60
+
61
+ // Remove from pending operations
62
+ pendingSuggestedEdits.delete(pendingFieldKey);
63
+ pendingSuggestedEditSaves.delete(saveKey);
64
+ }
65
+
48
66
  export function getOperationsContext(
49
67
  state: {
50
68
  page?: Page;
@@ -91,6 +109,12 @@ export function getOperationsContext(
91
109
  // Extract values to avoid state object reference issues
92
110
  const itemsRepository = state.itemsRepository;
93
111
 
112
+ // Debounced version of createOrUpdateSuggestedEdit to prevent excessive API calls
113
+ const debouncedCreateOrUpdateSuggestedEdit = useDebouncedCallback(
114
+ createOrUpdateSuggestedEdit,
115
+ 500, // 500ms delay
116
+ );
117
+
94
118
  const executeOp = useCallback(
95
119
  async (
96
120
  op: EditOperation,
@@ -187,7 +211,7 @@ export function getOperationsContext(
187
211
  if (executedOp.focus) {
188
212
  state.setSelection([executedOp.focus]);
189
213
  state.setFocusFieldComponentId(executedOp.focus);
190
-
214
+
191
215
  // Clear insert mode immediately after selecting the newly added component
192
216
  if (executedOp.type === "add-component") {
193
217
  state.setInsertMode(false);
@@ -336,7 +360,7 @@ export function getOperationsContext(
336
360
 
337
361
  if (op) {
338
362
  // Create and track the save promise
339
- const savePromise = createOrUpdateSuggestedEdit(op)
363
+ const savePromise = debouncedCreateOrUpdateSuggestedEdit(op)!
340
364
  .then((result) => {
341
365
  // Update the suggested edits state with the result from the server
342
366
  if (result.type === "success" && result.data) {
@@ -351,6 +375,9 @@ export function getOperationsContext(
351
375
  .finally(() => {
352
376
  // Remove from pending saves when complete
353
377
  pendingSuggestedEditSaves.delete(fieldKey);
378
+ // Also remove from pending edits since it's now persisted
379
+ const pendingFieldKey = `${field.item.id}:${field.item.language}:${field.item.version}:${field.fieldId}:${state.user?.name || "unknown"}`;
380
+ pendingSuggestedEdits.delete(pendingFieldKey);
354
381
  });
355
382
 
356
383
  pendingSuggestedEditSaves.set(fieldKey, savePromise);
@@ -807,6 +834,19 @@ async function getOrMergeSuggestedEditOp(
807
834
  }
808
835
  const page = state.page;
809
836
 
837
+ // Create a unique key for this field to check for pending edits
838
+ const fieldKey = `${field.item.id}:${field.item.language}:${field.item.version}:${field.fieldId}:${state.user?.name || "unknown"}`;
839
+
840
+ // First check if there's a pending suggested edit for this field
841
+ const pendingEdit = pendingSuggestedEdits.get(fieldKey);
842
+ if (pendingEdit) {
843
+ // Update the pending edit instead of creating a new one
844
+ pendingEdit.newValue = newVal || "";
845
+ pendingEdit.updated = new Date().toISOString();
846
+ pendingEdit.updatedBy = state.user?.name || "unknown";
847
+ return pendingEdit;
848
+ }
849
+
810
850
  // Attempt to find an existing suggested edit by the same user for this field.
811
851
  const existing = state.suggestedEdits.find(
812
852
  (edit) =>
@@ -819,6 +859,7 @@ async function getOrMergeSuggestedEditOp(
819
859
  edit.status === "pending", // or any status that indicates it's open for further editing.
820
860
  );
821
861
 
862
+
822
863
  if (existing) {
823
864
  // Update the existing suggestion.
824
865
  existing.newValue = newVal || "";
@@ -846,6 +887,9 @@ async function getOrMergeSuggestedEditOp(
846
887
  type: "FieldValue",
847
888
  };
848
889
 
890
+ // Add to pending edits to prevent race conditions
891
+ pendingSuggestedEdits.set(fieldKey, newEdit);
892
+
849
893
  state.setSuggestedEdits([...state.suggestedEdits, newEdit]);
850
894
  return newEdit;
851
895
  }
@@ -95,6 +95,7 @@ function getInsertCommand(
95
95
  const item = components[0];
96
96
  if (!item) return null;
97
97
  if (!item.placeholders || item.placeholders.length === 0) return null;
98
+ if (editContext.mode === "suggestions") return null;
98
99
  return {
99
100
  id: "insert",
100
101
  icon: <Plus size={defaultIconSize} />,
@@ -112,6 +113,7 @@ function getDuplicateCommand(
112
113
  editContext: EditContextType,
113
114
  ): ComponentCommand | null {
114
115
  if (components.length !== 1) return null;
116
+ if (editContext.mode === "suggestions") return null;
115
117
 
116
118
  return {
117
119
  id: "duplicate",
@@ -295,6 +297,8 @@ function getDeleteCommand(
295
297
  components: Component[],
296
298
  editContext: EditContextType,
297
299
  ): ComponentCommand | null {
300
+ if (editContext.mode === "suggestions") return null;
301
+
298
302
  const applicableComponents = components.filter(
299
303
  (c) =>
300
304
  ((!isPlaceholder(c) && !c.layoutId) ||
@@ -16,7 +16,10 @@ export function Thumbnails({
16
16
  onHide: () => void;
17
17
  }) {
18
18
  return (
19
- <div className="absolute inset-0 flex flex-col overflow-auto p-2">
19
+ <div
20
+ data-testid="media-selector-thumbnails-container"
21
+ className="absolute inset-0 flex flex-col overflow-auto p-2"
22
+ >
20
23
  <div className="flex flex-wrap content-start items-start gap-4">
21
24
  {images?.map((t) => (
22
25
  <div
@@ -388,6 +388,7 @@ export function FrameMenu({
388
388
  ev.stopPropagation();
389
389
  b.onClick(ev);
390
390
  }}
391
+ data-testid={`frame-menu-${b.id}`}
391
392
  >
392
393
  {typeof b.icon === "string" ? (
393
394
  <i className={b.icon + " cursor-pointer text-sm"} />
@@ -612,7 +612,7 @@ export function InlineEditor({
612
612
  // Otherwise, gather all suggestions for this field.
613
613
  const fieldSuggestions = context.suggestedEdits.filter(
614
614
  (s: any) =>
615
- s.status === "Pending" &&
615
+ s.status === "pending" &&
616
616
  s.fieldId === fieldId &&
617
617
  s.itemId === itemId &&
618
618
  s.mainItemLanguage === language &&
@@ -699,7 +699,7 @@ export function InlineEditor({
699
699
  ]);
700
700
 
701
701
  useEffect(() => {
702
- if (!context || compareView || context.mode === "preview") return;
702
+ if (!context || compareView) return;
703
703
 
704
704
  const iframeDoc = pageViewContext.editorIframe?.contentWindow?.document;
705
705
  if (!iframeDoc) return;
@@ -712,11 +712,35 @@ export function InlineEditor({
712
712
  );
713
713
 
714
714
  allFieldEls.forEach(async (el) => {
715
- if (context.mode === "suggestions" || context.showSuggestedEdits) return;
716
- // don't stomp on the one that's live‑editing
717
- if (el === context.inlineEditingFieldElement) return;
715
+ // don't stomp on the one that's live‑editing (only in non-preview modes)
716
+ if (
717
+ context.mode !== "preview" &&
718
+ el === context.inlineEditingFieldElement
719
+ )
720
+ return;
718
721
 
719
722
  const fieldId = el.getAttribute("data-fieldid")!;
723
+ const itemId = el.getAttribute("data-itemid")!;
724
+ const language = el.getAttribute("data-language")!;
725
+ const version = parseInt(el.getAttribute("data-version")!, 10);
726
+
727
+ // In suggestions mode (but not preview), only reset fields that don't have any suggested edits
728
+ if (
729
+ context.mode === "suggestions" ||
730
+ (context.mode !== "preview" && context.showSuggestedEdits)
731
+ ) {
732
+ const hasActiveSuggestions = context.suggestedEdits.some(
733
+ (edit) =>
734
+ edit.status === "pending" &&
735
+ edit.fieldId === fieldId &&
736
+ edit.itemId === itemId &&
737
+ edit.mainItemLanguage === language &&
738
+ edit.mainItemVersion === version,
739
+ );
740
+
741
+ // If field has active suggestions, don't reset it (let the other hook handle it)
742
+ if (hasActiveSuggestions) return;
743
+ }
720
744
 
721
745
  const realField = await context.itemsRepository.getItem({
722
746
  id: fieldId,
@@ -729,10 +753,6 @@ export function InlineEditor({
729
753
  return;
730
754
  }
731
755
 
732
- const itemId = el.getAttribute("data-itemid")!;
733
- const language = el.getAttribute("data-language")!;
734
- const version = parseInt(el.getAttribute("data-version")!, 10);
735
-
736
756
  // lookup baseline
737
757
  const loaded = await context.itemsRepository.getItem({
738
758
  id: itemId,
@@ -742,21 +762,23 @@ export function InlineEditor({
742
762
  const repoF = loaded?.fields.find((f) => f.id === fieldId);
743
763
  let value = repoF?.rawValue ?? "";
744
764
 
745
- // override with any local edit
746
- const mod = modifiedFieldsContext?.modifiedFields.find(
747
- (m) =>
748
- m.fieldId === fieldId &&
749
- m.item.id === itemId &&
750
- m.item.language === language &&
751
- m.item.version === version,
752
- );
765
+ // override with any local edit (only in non-preview modes)
766
+ if (context.mode !== "preview") {
767
+ const mod = modifiedFieldsContext?.modifiedFields.find(
768
+ (m) =>
769
+ m.fieldId === fieldId &&
770
+ m.item.id === itemId &&
771
+ m.item.language === language &&
772
+ m.item.version === version,
773
+ );
753
774
 
754
- if (mod) value = mod.value as string;
775
+ if (mod) value = mod.value as string;
776
+ }
755
777
 
756
778
  // write it in
757
779
  el.innerHTML = value;
758
780
  });
759
- }, [context.mode, context.showSuggestedEdits]);
781
+ }, [context.mode, context.showSuggestedEdits, context.suggestedEdits]);
760
782
 
761
783
  return null;
762
784
  }
@@ -5,6 +5,7 @@ import {
5
5
  deleteSuggestedEdit,
6
6
  createOrUpdateSuggestedEdit,
7
7
  } from "../services/suggestedEditsService";
8
+ import { cleanupPendingSuggestedEditOperations } from "../client/operations";
8
9
  import { Button } from "../../components/ui/button";
9
10
  import { formatDate } from "../utils";
10
11
  import { SimpleIconButton } from "../ui/SimpleIconButton";
@@ -171,8 +172,28 @@ export function SuggestedEditComponent({ edit }: { edit: SuggestedEditType }) {
171
172
  <Button
172
173
  variant="outline"
173
174
  onClick={async () => {
174
- await deleteSuggestedEdit(edit);
175
- setDeletePopoverOpen(false);
175
+ try {
176
+ await deleteSuggestedEdit(edit);
177
+ // Clean up pending operations to prevent race conditions
178
+ cleanupPendingSuggestedEditOperations(
179
+ edit,
180
+ editContext?.user,
181
+ );
182
+ // Immediately update local state to reflect deletion
183
+ if (
184
+ editContext?.setSuggestedEdits &&
185
+ editContext?.suggestedEdits
186
+ ) {
187
+ const updatedEdits = editContext.suggestedEdits.filter(
188
+ (e) => e.id !== edit.id,
189
+ );
190
+ editContext.setSuggestedEdits(updatedEdits);
191
+ }
192
+ } catch (error) {
193
+ console.error("Failed to delete suggested edit:", error);
194
+ } finally {
195
+ setDeletePopoverOpen(false);
196
+ }
176
197
  }}
177
198
  >
178
199
  Delete
@@ -15,6 +15,7 @@ import {
15
15
  deleteSuggestedEdit,
16
16
  createOrUpdateSuggestedEdit,
17
17
  } from "../services/suggestedEditsService";
18
+ import { cleanupPendingSuggestedEditOperations } from "../client/operations";
18
19
  import {
19
20
  Check,
20
21
  Trash2,
@@ -108,9 +109,23 @@ export function SuggestionDisplayPopover({
108
109
  return;
109
110
  }
110
111
 
111
- await deleteSuggestedEdit(suggestion);
112
- setIsOpen(false);
113
- onSuggestionUpdated?.();
112
+ try {
113
+ await deleteSuggestedEdit(suggestion);
114
+ // Clean up pending operations to prevent race conditions
115
+ cleanupPendingSuggestedEditOperations(suggestion, editContext?.user);
116
+ // Immediately update local state to reflect deletion
117
+ if (editContext?.setSuggestedEdits && editContext?.suggestedEdits) {
118
+ const updatedEdits = editContext.suggestedEdits.filter(
119
+ (e) => e.id !== suggestion.id,
120
+ );
121
+ editContext.setSuggestedEdits(updatedEdits);
122
+ }
123
+ } catch (error) {
124
+ console.error("Failed to delete suggested edit:", error);
125
+ } finally {
126
+ setIsOpen(false);
127
+ onSuggestionUpdated?.();
128
+ }
114
129
  };
115
130
 
116
131
  const handleApplyPatch = async () => {