@agent-native/core 0.182.1 → 0.182.2-nightly-20260917210345

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 (66) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/app/root.tsx +59 -10
  3. package/corpus/templates/clips/changelog/2026-09-17-desktop-popover-closes-when-clicking-away.md +6 -0
  4. package/corpus/templates/clips/changelog/2026-09-17-self-hosted-extension-sign-in.md +6 -0
  5. package/corpus/templates/clips/chrome-extension/src/background.ts +45 -25
  6. package/corpus/templates/clips/chrome-extension/src/content-script.ts +43 -0
  7. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +55 -6
  8. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +1 -0
  9. package/corpus/templates/design/actions/add-localhost-screens.ts +54 -22
  10. package/corpus/templates/design/actions/apply-visual-edit.ts +1 -0
  11. package/corpus/templates/design/actions/create-design-from-template.ts +50 -35
  12. package/corpus/templates/design/actions/create-design-version.ts +21 -0
  13. package/corpus/templates/design/actions/create-file.ts +35 -33
  14. package/corpus/templates/design/actions/delete-design.ts +29 -28
  15. package/corpus/templates/design/actions/delete-file.ts +154 -23
  16. package/corpus/templates/design/actions/generate-design.ts +28 -16
  17. package/corpus/templates/design/actions/get-component-details.ts +187 -33
  18. package/corpus/templates/design/actions/get-design-version.ts +9 -1
  19. package/corpus/templates/design/actions/go-to-main-component.ts +37 -35
  20. package/corpus/templates/design/actions/index-components.ts +241 -126
  21. package/corpus/templates/design/actions/migrate-board-objects-to-file.ts +12 -9
  22. package/corpus/templates/design/actions/present-design-variants.ts +68 -72
  23. package/corpus/templates/design/actions/rename-component.ts +294 -0
  24. package/corpus/templates/design/actions/rename-screen.ts +10 -10
  25. package/corpus/templates/design/actions/update-file.ts +327 -330
  26. package/corpus/templates/design/actions/update-screen-source.ts +2 -43
  27. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +1 -0
  28. package/corpus/templates/design/app/components/design/code-workbench/CodeWorkbench.tsx +8 -1
  29. package/corpus/templates/design/app/components/design/code-workbench/explorer/FileTree.tsx +55 -33
  30. package/corpus/templates/design/app/components/design/code-workbench/workspace/create-providers.ts +2 -0
  31. package/corpus/templates/design/app/components/design/code-workbench/workspace/inline-provider.ts +4 -2
  32. package/corpus/templates/design/app/components/design/edit-panel/element-classification.ts +5 -1
  33. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +57 -9
  34. package/corpus/templates/design/app/components/design/types.ts +1 -0
  35. package/corpus/templates/design/app/hooks/use-design-versions.ts +3 -2
  36. package/corpus/templates/design/app/i18n-breakpoints.ts +0 -110
  37. package/corpus/templates/design/app/i18n-data.ts +0 -10
  38. package/corpus/templates/design/app/pages/DesignEditor.tsx +250 -107
  39. package/corpus/templates/design/app/pages/design-editor/commands/apply-file-content-update.ts +2 -0
  40. package/corpus/templates/design/app/pages/design-editor/commands/delete-files.ts +69 -47
  41. package/corpus/templates/design/app/pages/design-editor/commands/redo.ts +8 -1
  42. package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +107 -2
  43. package/corpus/templates/design/app/pages/design-editor/commands/undo.ts +26 -1
  44. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +63 -2
  45. package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +74 -22
  46. package/corpus/templates/design/app/pages/design-editor/source-publication.ts +14 -2
  47. package/corpus/templates/design/changelog/2026-09-16-design-components-retain-linked-identities-when-edited-and-d.md +6 -0
  48. package/corpus/templates/design/changelog/2026-09-17-screen-deletion-is-immediate-and-undoable-with-history-that-.md +6 -0
  49. package/corpus/templates/design/e2e/fixtures/design-component-react/index.html +98 -0
  50. package/corpus/templates/design/e2e/fixtures/design-component-react/inline-component.html +65 -0
  51. package/corpus/templates/design/server/lib/design-data-mutation.ts +9 -5
  52. package/corpus/templates/design/server/lib/design-snapshot.ts +28 -16
  53. package/corpus/templates/design/server/lib/design-versions.ts +208 -130
  54. package/corpus/templates/design/server/lib/fusion-screens.ts +46 -20
  55. package/corpus/templates/design/server/lib/import-design-files.ts +5 -1
  56. package/corpus/templates/design/server/lib/localhost-connection.ts +54 -0
  57. package/corpus/templates/design/server/source-workspace.ts +412 -356
  58. package/corpus/templates/design/shared/component-library.ts +36 -6
  59. package/corpus/templates/design/shared/component-model.ts +8 -2
  60. package/dist/automation/index.d.ts +1 -1
  61. package/dist/client/chat/agentkit-protocol.js +148 -34
  62. package/dist/collab/struct-routes.d.ts +1 -1
  63. package/dist/progress/routes.d.ts +1 -1
  64. package/dist/server/core-routes-plugin.d.ts +1 -1
  65. package/package.json +4 -4
  66. package/corpus/templates/design/app/components/design/editor/PendingScreenDeletionDialog.tsx +0 -66
@@ -37,6 +37,7 @@ export interface ApplyFileContentUpdateArgs {
37
37
  refreshPreview?: boolean;
38
38
  skipPreview?: boolean;
39
39
  forcePreviewFullDocument?: boolean;
40
+ immediateSave?: boolean;
40
41
  persist?: boolean;
41
42
  recordHistory?: boolean;
42
43
  historyBeforeContent?: string;
@@ -126,6 +127,7 @@ export function runApplyFileContentUpdate(
126
127
  refreshPreview?: boolean;
127
128
  skipPreview?: boolean;
128
129
  forcePreviewFullDocument?: boolean;
130
+ immediateSave?: boolean;
129
131
  persist?: boolean;
130
132
  recordHistory?: boolean;
131
133
  historyBeforeContent?: string;
@@ -109,6 +109,8 @@ export interface DeleteFilesArgs {
109
109
  fileCreationUndoStackRef: RefObject<FileCreationHistoryEntry[]>;
110
110
  fileDeletionUndoStackRef: RefObject<FileDeletionHistoryEntry[]>;
111
111
  fileHistoryMutationPendingRef: RefObject<boolean>;
112
+ captureHistoryCheckpoint?: () => Promise<string>;
113
+ clearPendingHistory?: () => void;
112
114
  files: DesignFile[];
113
115
  geometryRedoStackRef: RefObject<GeometryHistoryEntry[]>;
114
116
  geometryUndoStackRef: RefObject<GeometryHistoryEntry[]>;
@@ -132,7 +134,7 @@ export interface DeleteFilesArgs {
132
134
  ) => void;
133
135
  }
134
136
 
135
- export function runDeleteFiles(
137
+ export async function runDeleteFiles(
136
138
  {
137
139
  activeFile,
138
140
  canvasFrameGeometryById,
@@ -149,6 +151,8 @@ export function runDeleteFiles(
149
151
  fileCreationUndoStackRef,
150
152
  fileDeletionUndoStackRef,
151
153
  fileHistoryMutationPendingRef,
154
+ captureHistoryCheckpoint,
155
+ clearPendingHistory,
152
156
  files,
153
157
  geometryRedoStackRef,
154
158
  geometryUndoStackRef,
@@ -186,8 +190,11 @@ export function runDeleteFiles(
186
190
  failedFiles: DesignFile[],
187
191
  ) => void;
188
192
  },
189
- ) {
193
+ ): Promise<void> {
190
194
  if (!filesToDelete.length) return;
195
+ if (options?.recordDeletionHistory && fileHistoryMutationPendingRef.current) {
196
+ throw new Error(t("common.genericError"));
197
+ }
191
198
  const deleteIds = new Set(filesToDelete.map((file) => file.id));
192
199
  const nextActiveFile = files.find((file) => !deleteIds.has(file.id));
193
200
  const nextGeometry = cloneCanvasFrameGeometry(canvasFrameGeometryById);
@@ -201,10 +208,24 @@ export function runDeleteFiles(
201
208
  })),
202
209
  }
203
210
  : null;
211
+ let historyCheckpointId: string | undefined;
204
212
  if (deletionHistoryEntry) {
205
- clearRedoStacks();
206
213
  fileHistoryMutationPendingRef.current = true;
207
214
  syncUndoRedoState();
215
+ if (captureHistoryCheckpoint) {
216
+ try {
217
+ historyCheckpointId = await captureHistoryCheckpoint();
218
+ } catch (error) {
219
+ fileHistoryMutationPendingRef.current = false;
220
+ clearPendingHistory?.();
221
+ syncUndoRedoState();
222
+ toast.error(
223
+ error instanceof Error ? error.message : t("common.genericError"),
224
+ );
225
+ return;
226
+ }
227
+ }
228
+ clearRedoStacks();
208
229
  }
209
230
  filesToDelete.forEach((file) => {
210
231
  delete nextGeometry[file.id];
@@ -371,61 +392,62 @@ export function runDeleteFiles(
371
392
  setSelectedElement(null);
372
393
  setSelectedLayerIdsState([]);
373
394
 
374
- void Promise.allSettled(
395
+ const results = await Promise.allSettled(
375
396
  filesToDelete.map((file) =>
376
397
  deleteFileMutation.mutateAsync({
377
398
  id: file.id,
378
399
  allowLockedLayers: true,
400
+ ...(historyCheckpointId ? { historyCheckpointId } : {}),
379
401
  } as any),
380
402
  ),
381
- ).then((results) => {
382
- const deletedFiles = filesToDelete.filter((_, index) => {
383
- const result = results[index];
384
- return (
385
- result?.status === "fulfilled" &&
386
- (result.value as { deleted?: boolean } | undefined)?.deleted !== false
387
- );
388
- });
389
- const deletedIds = new Set(deletedFiles.map((file) => file.id));
390
- const failedFiles = filesToDelete.filter(
391
- (file) => !deletedIds.has(file.id),
403
+ );
404
+ const deletedFiles = filesToDelete.filter((_, index) => {
405
+ const result = results[index];
406
+ return (
407
+ result?.status === "fulfilled" &&
408
+ (result.value as { deleted?: boolean } | undefined)?.deleted !== false
392
409
  );
410
+ });
411
+ const deletedIds = new Set(deletedFiles.map((file) => file.id));
412
+ const failedFiles = filesToDelete.filter((file) => !deletedIds.has(file.id));
393
413
 
394
- if (deletionHistoryEntry && deletedFiles.length > 0) {
395
- fileDeletionUndoStackRef.current = [
396
- ...fileDeletionUndoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
397
- filterFileDeletionHistoryEntry(deletionHistoryEntry, deletedIds),
398
- ];
399
- historyOrderRef.current = [
400
- ...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
401
- "file-deleted",
402
- ];
403
- }
414
+ if (deletionHistoryEntry && deletedFiles.length > 0) {
415
+ fileDeletionUndoStackRef.current = [
416
+ ...fileDeletionUndoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
417
+ filterFileDeletionHistoryEntry(deletionHistoryEntry, deletedIds),
418
+ ];
419
+ historyOrderRef.current = [
420
+ ...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
421
+ "file-deleted",
422
+ ];
423
+ }
404
424
 
405
- const rejected = results.find(
406
- (result): result is PromiseRejectedResult => result.status === "rejected",
407
- );
408
- if (failedFiles.length > 0) {
409
- void queryClient.invalidateQueries({
410
- queryKey: ["action", "get-design"],
411
- });
412
- if (rejected) {
413
- toast.error(
414
- rejected.reason instanceof Error
415
- ? rejected.reason.message
416
- : t("common.genericError"),
417
- );
418
- }
425
+ const rejected = results.find(
426
+ (result): result is PromiseRejectedResult => result.status === "rejected",
427
+ );
428
+ if (failedFiles.length > 0) {
429
+ void queryClient.invalidateQueries({
430
+ queryKey: ["action", "get-design"],
431
+ });
432
+ if (rejected) {
433
+ toast.error(
434
+ rejected.reason instanceof Error
435
+ ? rejected.reason.message
436
+ : t("common.genericError"),
437
+ );
419
438
  }
439
+ // A partial/failed batch is not a safe boundary for a queued undo/redo.
440
+ // The surviving user intent must be reissued explicitly after refresh.
441
+ if (deletionHistoryEntry) clearPendingHistory?.();
442
+ }
420
443
 
421
- if (deletionHistoryEntry) {
422
- fileHistoryMutationPendingRef.current = false;
423
- for (const fileId of deletedIds)
424
- latestClipboardMutationContentRef.current.delete(fileId);
425
- }
426
- options?.onMutationSettled?.(deletedFiles, failedFiles);
427
- syncUndoRedoState();
428
- });
444
+ if (deletionHistoryEntry) {
445
+ fileHistoryMutationPendingRef.current = false;
446
+ for (const fileId of deletedIds)
447
+ latestClipboardMutationContentRef.current.delete(fileId);
448
+ }
449
+ options?.onMutationSettled?.(deletedFiles, failedFiles);
450
+ syncUndoRedoState();
429
451
 
430
452
  // File-backed screen deletion is not a geometry-only edit. The screen rows
431
453
  // are hard-deleted, so suppress MultiScreenCanvas' local frame-history
@@ -153,7 +153,9 @@ export interface RedoArgs {
153
153
  fileDeletionRedoStackRef: RefObject<FileDeletionHistoryEntry[]>;
154
154
  fileDeletionUndoStackRef: RefObject<FileDeletionHistoryEntry[]>;
155
155
  fileHistoryMutationPendingRef: RefObject<boolean>;
156
+ clearPendingHistory?: () => void;
156
157
  files: DesignFile[];
158
+ filesRef?: RefObject<DesignFile[]>;
157
159
  focusCreatedScreen: (screenId: string, geometry: FrameGeometry) => void;
158
160
  geometryRedoStackRef: RefObject<GeometryHistoryEntry[]>;
159
161
  geometryUndoStackRef: RefObject<GeometryHistoryEntry[]>;
@@ -335,7 +337,9 @@ export function runRedo({
335
337
  fileDeletionRedoStackRef,
336
338
  fileDeletionUndoStackRef,
337
339
  fileHistoryMutationPendingRef,
340
+ clearPendingHistory,
338
341
  files,
342
+ filesRef,
339
343
  focusCreatedScreen,
340
344
  geometryRedoStackRef,
341
345
  geometryUndoStackRef,
@@ -399,8 +403,9 @@ export function runRedo({
399
403
  selection: GeometryHistorySelection | undefined,
400
404
  replaySources: Record<string, string> = {},
401
405
  ) => {
406
+ const currentFiles = filesRef?.current ?? files;
402
407
  const actualSources = Object.fromEntries(
403
- files.map((file) => [
408
+ currentFiles.map((file) => [
404
409
  file.id,
405
410
  replaySources[file.id] ?? getScreenContent(file.id),
406
411
  ]),
@@ -1226,6 +1231,7 @@ export function runRedo({
1226
1231
  ...redoOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
1227
1232
  "file-created",
1228
1233
  ];
1234
+ clearPendingHistory?.();
1229
1235
  fileHistoryMutationPendingRef.current = false;
1230
1236
  syncUndoRedoState();
1231
1237
  toast.error(
@@ -1280,6 +1286,7 @@ export function runRedo({
1280
1286
  ...redoOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
1281
1287
  "file-deleted",
1282
1288
  ];
1289
+ clearPendingHistory?.();
1283
1290
  }
1284
1291
  fileHistoryMutationPendingRef.current = false;
1285
1292
  syncUndoRedoState();
@@ -1,4 +1,5 @@
1
1
  import { useActionMutation } from "@agent-native/core/client/hooks";
2
+ import { sourceContentHash } from "@shared/source-workspace";
2
3
  import type { QueryClient } from "@tanstack/react-query";
3
4
  import type { Dispatch, RefObject, SetStateAction } from "react";
4
5
  import { toast } from "sonner";
@@ -7,7 +8,11 @@ import type { DesignSaveOutboxEntry } from "@/lib/design-save-outbox";
7
8
  import { updateFileResultPersistedContent } from "@/lib/design-save-outbox";
8
9
  import type { PatchProofState } from "@/pages/design-editor/command-types";
9
10
  import type { FileContentSaveRequest } from "@/pages/design-editor/editor-state";
10
- import { shouldClearLatestUnloadSave } from "@/pages/design-editor/editor-state";
11
+ import {
12
+ advanceLatestUnloadSaveBase,
13
+ prepareFileContentSaveKeepalive,
14
+ shouldClearLatestUnloadSave,
15
+ } from "@/pages/design-editor/editor-state";
11
16
  import {
12
17
  classifyDesignSaveFailure,
13
18
  designSaveErrorMessage,
@@ -43,6 +48,80 @@ export interface SaveFileContentArgs {
43
48
  warnChangesWillRetry: () => void;
44
49
  }
45
50
 
51
+ type FileContentSaveKeepaliveAttempt =
52
+ | { accepted: true; completion: Promise<unknown> }
53
+ | { accepted: false; completion: null };
54
+
55
+ export interface SaveFileContentKeepaliveArgs {
56
+ acknowledgeOutboxEntry: (entry: DesignSaveOutboxEntry) => Promise<void>;
57
+ createFileSaveOutboxEntry: (
58
+ pending: FileContentSaveRequest,
59
+ ) => DesignSaveOutboxEntry | null;
60
+ journalOutboxEntry: (entry: DesignSaveOutboxEntry) => Promise<boolean>;
61
+ latestFileSaveForUnloadRef: RefObject<Record<string, FileContentSaveRequest>>;
62
+ sendKeepalive: (
63
+ payload: Record<string, unknown>,
64
+ ) => FileContentSaveKeepaliveAttempt;
65
+ }
66
+
67
+ export function runFileContentSaveKeepalive(
68
+ {
69
+ acknowledgeOutboxEntry,
70
+ createFileSaveOutboxEntry,
71
+ journalOutboxEntry,
72
+ latestFileSaveForUnloadRef,
73
+ sendKeepalive,
74
+ }: SaveFileContentKeepaliveArgs,
75
+ pending: FileContentSaveRequest,
76
+ ) {
77
+ // Keep the folded oldest-base entry durable while the direct request uses
78
+ // this edit's own base. If the predecessor never lands, replay must start
79
+ // from the oldest known version rather than the successor's base.
80
+ const durableEntry = createFileSaveOutboxEntry(pending);
81
+ const keepaliveEntry = createFileSaveOutboxEntry(
82
+ prepareFileContentSaveKeepalive(pending),
83
+ );
84
+ if (!durableEntry || !keepaliveEntry) return;
85
+ const journalPromise = journalOutboxEntry(durableEntry);
86
+ const attempt = sendKeepalive(keepaliveEntry.payload);
87
+ if (!attempt.accepted) {
88
+ void journalPromise.catch(() => {});
89
+ return;
90
+ }
91
+ void attempt.completion
92
+ .then(async (result: unknown) => {
93
+ await journalPromise;
94
+ const persistedContentMatches = updateFileResultPersistedContent(
95
+ result,
96
+ pending.content,
97
+ );
98
+ if (!persistedContentMatches) return;
99
+ const resultInfo = result as { versionHash?: string } | undefined;
100
+ const latest = latestFileSaveForUnloadRef.current[pending.id];
101
+ if (shouldClearLatestUnloadSave(latest, pending)) {
102
+ await acknowledgeOutboxEntry(durableEntry);
103
+ delete latestFileSaveForUnloadRef.current[pending.id];
104
+ return;
105
+ }
106
+ if (
107
+ advanceLatestUnloadSaveBase(
108
+ latest,
109
+ pending,
110
+ resultInfo?.versionHash ?? sourceContentHash(pending.content),
111
+ )
112
+ ) {
113
+ const advancedOutboxEntry = latest
114
+ ? createFileSaveOutboxEntry(latest)
115
+ : null;
116
+ if (advancedOutboxEntry) {
117
+ await journalOutboxEntry(advancedOutboxEntry);
118
+ }
119
+ }
120
+ await acknowledgeOutboxEntry(durableEntry);
121
+ })
122
+ .catch(() => {});
123
+ }
124
+
46
125
  export function runSaveFileContent(
47
126
  {
48
127
  acknowledgeOutboxEntry,
@@ -120,6 +199,30 @@ export function runSaveFileContent(
120
199
  pending.content,
121
200
  t("common.genericError"),
122
201
  );
202
+ const latest = latestFileSaveForUnloadRef.current[pending.id];
203
+ if (
204
+ persistedContentMatches &&
205
+ advanceLatestUnloadSaveBase(
206
+ latest,
207
+ pending,
208
+ resultInfo?.versionHash ?? sourceContentHash(pending.content),
209
+ )
210
+ ) {
211
+ const advancedOutboxEntry = latest
212
+ ? createFileSaveOutboxEntry(latest)
213
+ : null;
214
+ if (advancedOutboxEntry)
215
+ await journalOutboxEntry(advancedOutboxEntry);
216
+ }
217
+ if (
218
+ persistedContentMatches &&
219
+ pending.identityMigrationSourceContent !== undefined &&
220
+ latestFileSaveForUnloadRef.current[pending.id] === pending
221
+ ) {
222
+ // Identity repair has landed. Retire its raw-base marker so the next
223
+ // user edit publishes against the canonical bytes it already sees.
224
+ markPendingLocalFileContent(pending.id, pending.content);
225
+ }
123
226
  if (persistedContentMatches && outboxEntry) {
124
227
  await acknowledgeOutboxEntry(outboxEntry);
125
228
  } else if (!persistedContentMatches) {
@@ -143,7 +246,6 @@ export function runSaveFileContent(
143
246
  shouldClearLatestUnloadSave(
144
247
  latestFileSaveForUnloadRef.current[pending.id],
145
248
  pending,
146
- !persistedContentMatches,
147
249
  )
148
250
  ) {
149
251
  delete latestFileSaveForUnloadRef.current[pending.id];
@@ -180,6 +282,9 @@ export function runSaveFileContent(
180
282
  if (failureKind === "conflict") {
181
283
  // Roll back our optimistic bytes before the refetch can race ahead.
182
284
  rollbackPendingLocalFileContent(pending.id, pending.content);
285
+ if (latestFileSaveForUnloadRef.current[pending.id] === pending) {
286
+ delete latestFileSaveForUnloadRef.current[pending.id];
287
+ }
183
288
  }
184
289
  void queryClient.invalidateQueries({
185
290
  queryKey: ["action", "get-design"],
@@ -410,6 +410,13 @@ export interface UndoArgs {
410
410
  createFileMutation: ReturnType<
411
411
  typeof useActionMutation<undefined, undefined, "create-file">
412
412
  >;
413
+ optimisticallyInsertCreatedFile?: (args: {
414
+ fileId: string;
415
+ filename: string;
416
+ fileType: DesignFile["fileType"];
417
+ content: string;
418
+ result?: Record<string, unknown> | null;
419
+ }) => void;
413
420
  deleteFileMutation: ReturnType<
414
421
  typeof useActionMutation<undefined, undefined, "delete-file">
415
422
  >;
@@ -419,7 +426,9 @@ export interface UndoArgs {
419
426
  fileDeletionRedoStackRef: RefObject<FileDeletionHistoryEntry[]>;
420
427
  fileDeletionUndoStackRef: RefObject<FileDeletionHistoryEntry[]>;
421
428
  fileHistoryMutationPendingRef: RefObject<boolean>;
429
+ clearPendingHistory?: () => void;
422
430
  files: DesignFile[];
431
+ filesRef?: RefObject<DesignFile[]>;
423
432
  geometryRedoStackRef: RefObject<GeometryHistoryEntry[]>;
424
433
  geometryUndoStackRef: RefObject<GeometryHistoryEntry[]>;
425
434
  getFreshActiveContent: () => string;
@@ -547,6 +556,7 @@ export function runUndo({
547
556
  contentUndoSelectionStackRef,
548
557
  contentUndoStackRef,
549
558
  createFileMutation,
559
+ optimisticallyInsertCreatedFile,
550
560
  deleteFileMutation,
551
561
  designDataJsonRef,
552
562
  fileCreationRedoStackRef,
@@ -554,7 +564,9 @@ export function runUndo({
554
564
  fileDeletionRedoStackRef,
555
565
  fileDeletionUndoStackRef,
556
566
  fileHistoryMutationPendingRef,
567
+ clearPendingHistory,
557
568
  files,
569
+ filesRef,
558
570
  geometryRedoStackRef,
559
571
  geometryUndoStackRef,
560
572
  getFreshActiveContent,
@@ -609,8 +621,9 @@ export function runUndo({
609
621
  selection: GeometryHistorySelection | undefined,
610
622
  replaySources: Record<string, string> = {},
611
623
  ) => {
624
+ const currentFiles = filesRef?.current ?? files;
612
625
  const actualSources = Object.fromEntries(
613
- files.map((file) => [
626
+ currentFiles.map((file) => [
614
627
  file.id,
615
628
  replaySources[file.id] ?? getScreenContent(file.id),
616
629
  ]),
@@ -1478,6 +1491,17 @@ export function runUndo({
1478
1491
  ...file,
1479
1492
  content: preparedFiles[index]?.content ?? file.content,
1480
1493
  }));
1494
+ entry.files.forEach((file, index) => {
1495
+ const recreatedFile = recreatedEntry.files[index];
1496
+ const prepared = preparedFiles[index];
1497
+ if (!recreatedFile || !prepared) return;
1498
+ optimisticallyInsertCreatedFile?.({
1499
+ fileId: recreatedFile.id,
1500
+ filename: file.filename,
1501
+ fileType: file.fileType,
1502
+ content: prepared.content,
1503
+ });
1504
+ });
1481
1505
  const metadataRestore = restoreMetadataAndGeometry(
1482
1506
  restoreEntry,
1483
1507
  recreatedEntry,
@@ -1548,6 +1572,7 @@ export function runUndo({
1548
1572
  setSelectedLayerIdsState(recreatedEntry.files.map((file) => file.id));
1549
1573
  }
1550
1574
  } catch (error) {
1575
+ clearPendingHistory?.();
1551
1576
  const cleanupResults = await Promise.allSettled(
1552
1577
  recreatedIds.map((fileId) =>
1553
1578
  deleteFileMutation.mutateAsync({
@@ -599,6 +599,12 @@ export function restorePendingFileContent<
599
599
 
600
600
  export interface FileContentSaveRequest {
601
601
  identityMigrationSourceContent?: string;
602
+ /**
603
+ * CAS base for the durable latest-content replay. Normal saves keep their
604
+ * per-edit base so the serialized chain remains strict; the outbox needs the
605
+ * oldest unacknowledged base so a pagehide can replay the full snapshot.
606
+ */
607
+ unloadExpectedVersionHash?: string;
602
608
  id: string;
603
609
  content: string;
604
610
  syncCollab: boolean;
@@ -610,6 +616,19 @@ export interface FileContentSaveRequest {
610
616
  expectedVersionHash: string;
611
617
  }
612
618
 
619
+ /**
620
+ * A pagehide request must retain its own CAS base. The durable outbox may fold
621
+ * later edits onto the oldest base, but a direct keepalive can race that
622
+ * predecessor and must remain replayable in operation order.
623
+ */
624
+ export function prepareFileContentSaveKeepalive(
625
+ pending: FileContentSaveRequest,
626
+ ): FileContentSaveRequest {
627
+ return pending.unloadExpectedVersionHash === undefined
628
+ ? pending
629
+ : { ...pending, unloadExpectedVersionHash: undefined };
630
+ }
631
+
613
632
  export function coalescePendingFileContentSave(
614
633
  next: FileContentSaveRequest,
615
634
  pending: FileContentSaveRequest | undefined,
@@ -637,10 +656,8 @@ type FileContentSaveRequestsById = Readonly<
637
656
  export function shouldClearLatestUnloadSave(
638
657
  latest: FileContentSaveRequest | undefined,
639
658
  completed: FileContentSaveRequest,
640
- skippedStaleMirror = false,
641
659
  ): boolean {
642
660
  return Boolean(
643
- !skippedStaleMirror &&
644
661
  latest &&
645
662
  latest.id === completed.id &&
646
663
  latest.content === completed.content &&
@@ -650,6 +667,50 @@ export function shouldClearLatestUnloadSave(
650
667
  );
651
668
  }
652
669
 
670
+ /**
671
+ * A completed predecessor advances a newer unload replay from its old CAS
672
+ * base. Mutate the request in place so debounce slots and save chains keep the
673
+ * same request object, then let the caller re-journal its updated payload.
674
+ */
675
+ export function advanceLatestUnloadSaveBase(
676
+ latest: FileContentSaveRequest | undefined,
677
+ completed: FileContentSaveRequest,
678
+ persistedVersionHash: string,
679
+ ): boolean {
680
+ const completedBase =
681
+ completed.unloadExpectedVersionHash ?? completed.expectedVersionHash;
682
+ const latestBase =
683
+ latest?.unloadExpectedVersionHash ?? latest?.expectedVersionHash;
684
+ if (
685
+ !latest ||
686
+ latest === completed ||
687
+ latest.id !== completed.id ||
688
+ latest.operationSource !== completed.operationSource ||
689
+ latest.operationRevision <= completed.operationRevision ||
690
+ latestBase !== completedBase
691
+ ) {
692
+ return false;
693
+ }
694
+ latest.unloadExpectedVersionHash = persistedVersionHash;
695
+ return true;
696
+ }
697
+
698
+ export function shouldClearLatestUnloadSaveForOutboxEntry(
699
+ latest: FileContentSaveRequest | undefined,
700
+ entry: {
701
+ resourceId: string;
702
+ operationSource: string;
703
+ operationRevision: number;
704
+ },
705
+ ): boolean {
706
+ return Boolean(
707
+ latest &&
708
+ latest.id === entry.resourceId &&
709
+ latest.operationSource === entry.operationSource &&
710
+ latest.operationRevision === entry.operationRevision,
711
+ );
712
+ }
713
+
653
714
  /**
654
715
  * Flushes debounce slots through the ordinary serialized mutation path during
655
716
  * React cleanup (route/design changes). Real document unload is handled by the
@@ -421,6 +421,19 @@ function isRenderedBlockDisplay(display: string | null | undefined): boolean {
421
421
  return display === "block" || display === "list-item";
422
422
  }
423
423
 
424
+ function hasRenderedGridPlacement(
425
+ computedStyles: Record<string, string> | undefined,
426
+ ): boolean {
427
+ return [computedStyles?.gridColumn, computedStyles?.gridRow].some((value) => {
428
+ const normalized = value?.trim().replace(/\s+/g, " ").toLowerCase();
429
+ if (!normalized) return false;
430
+ return normalized.split("/").some((line) => {
431
+ const trimmed = line.trim();
432
+ return trimmed !== "auto" && !/^span(?:\s|$)/.test(trimmed);
433
+ });
434
+ });
435
+ }
436
+
424
437
  export interface ResolveElementNudgeIntentArgs {
425
438
  content: string;
426
439
  source?: CodeLayerSource;
@@ -477,14 +490,45 @@ export function resolveElementNudgeIntent(
477
490
  // round-trip has happened yet and `parentDisplay` is simply absent. An
478
491
  // element that really is inline or a flex child is handled above — the parser
479
492
  // sees those, and a rendered value always wins over this default.
480
- const rendered = args.selectedElement.parentDisplay;
481
- // A rendered grid needs its column count to map an arrow onto the next visual
482
- // cell, and `display: grid` alone does not carry it. Guessing "flex row" walks
483
- // DOM order instead, which is a different element in any multi-column grid.
484
- if (
493
+ const rendered =
494
+ args.selectedElement.parentDisplay ??
495
+ args.selectedElement.parentLayout?.display;
496
+ // A rendered grid needs its track count to map an arrow onto the next visual
497
+ // cell, and `display: grid` alone does not carry it. Reuse the computed track
498
+ // templates already reported by the bridge instead of guessing a flex row.
499
+ const renderedGrid =
485
500
  parsedContainer.kind === "none" &&
486
501
  !escapesFlow(position) &&
487
502
  (rendered === "grid" || rendered === "inline-grid")
503
+ ? describeFlowContainer({
504
+ style: {
505
+ display: rendered,
506
+ ...(args.selectedElement.parentLayout?.gridTemplateColumns
507
+ ? {
508
+ "grid-template-columns":
509
+ args.selectedElement.parentLayout.gridTemplateColumns,
510
+ }
511
+ : {}),
512
+ ...(args.selectedElement.parentLayout?.gridTemplateRows
513
+ ? {
514
+ "grid-template-rows":
515
+ args.selectedElement.parentLayout.gridTemplateRows,
516
+ }
517
+ : {}),
518
+ ...(args.selectedElement.parentLayout?.gridAutoFlow
519
+ ? {
520
+ "grid-auto-flow":
521
+ args.selectedElement.parentLayout.gridAutoFlow,
522
+ }
523
+ : {}),
524
+ },
525
+ })
526
+ : NO_FLOW_CONTAINER;
527
+ if (
528
+ parsedContainer.kind === "none" &&
529
+ !escapesFlow(position) &&
530
+ (rendered === "grid" || rendered === "inline-grid") &&
531
+ (renderedGrid.kind !== "grid" || renderedGrid.lineLength === null)
488
532
  ) {
489
533
  return { kind: "none" };
490
534
  }
@@ -496,29 +540,31 @@ export function resolveElementNudgeIntent(
496
540
  if (
497
541
  parsedContainer.kind === "none" &&
498
542
  !escapesFlow(position) &&
499
- isRenderedFlowDisplay(rendered) &&
543
+ (rendered === "flex" || rendered === "inline-flex") &&
500
544
  !renderedFlexDirection
501
545
  ) {
502
546
  return { kind: "none" };
503
547
  }
504
548
  const container: FlowContainerInfo =
505
549
  parsedContainer.kind === "none" && !escapesFlow(position)
506
- ? isRenderedFlowDisplay(rendered)
507
- ? {
508
- ...NO_FLOW_CONTAINER,
509
- kind: "flex",
510
- axis: renderedFlexDirection?.startsWith("column")
511
- ? "vertical"
512
- : "horizontal",
513
- reversed: renderedFlexDirection?.endsWith("-reverse") ?? false,
514
- }
515
- : // `parent` null means the node is a projection root: it has no flow to
516
- // reorder within, and the bridge reports `parentDisplay: undefined`
517
- // for it exactly as it does for a not-yet-measured selection.
518
- isRenderedBlockDisplay(rendered) ||
519
- (rendered === undefined && parent !== null)
520
- ? BLOCK_FLOW_CONTAINER
521
- : parsedContainer
550
+ ? renderedGrid.kind === "grid"
551
+ ? renderedGrid
552
+ : isRenderedFlowDisplay(rendered)
553
+ ? {
554
+ ...NO_FLOW_CONTAINER,
555
+ kind: "flex",
556
+ axis: renderedFlexDirection?.startsWith("column")
557
+ ? "vertical"
558
+ : "horizontal",
559
+ reversed: renderedFlexDirection?.endsWith("-reverse") ?? false,
560
+ }
561
+ : // `parent` null means the node is a projection root: it has no flow to
562
+ // reorder within, and the bridge reports `parentDisplay: undefined`
563
+ // for it exactly as it does for a not-yet-measured selection.
564
+ isRenderedBlockDisplay(rendered) ||
565
+ (rendered === undefined && parent !== null)
566
+ ? BLOCK_FLOW_CONTAINER
567
+ : parsedContainer
522
568
  : parsedContainer;
523
569
  // Flex/grid paint children by `order` and explicit grid placement, not DOM
524
570
  // position, so moving the node would write a source change that produces no
@@ -552,6 +598,12 @@ export function resolveElementNudgeIntent(
552
598
  ) {
553
599
  return { kind: "none" };
554
600
  }
601
+ if (
602
+ container.kind === "grid" &&
603
+ hasRenderedGridPlacement(args.selectedElement.computedStyles)
604
+ ) {
605
+ return { kind: "none" };
606
+ }
555
607
 
556
608
  const intent = resolveNudgeIntent({
557
609
  direction: args.direction,