@agent-native/core 0.182.1-nightly-20260917183518 → 0.182.1

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 (56) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/CHANGELOG.md +6 -0
  3. package/corpus/templates/calendar/CHANGELOG.md +1 -0
  4. package/corpus/templates/clips/CHANGELOG.md +7 -0
  5. package/corpus/templates/clips/app/components/library/sort-menu.tsx +2 -2
  6. package/corpus/templates/clips/changelog/2026-09-17-clips-waits-for-authentication-before-showing-the-recording-.md +6 -0
  7. package/corpus/templates/clips/changelog/2026-09-17-the-clips-sort-control-keeps-its-keyboard-focus-ring-visible.md +6 -0
  8. package/corpus/templates/clips/chrome-extension/src/popup.html +1 -1
  9. package/corpus/templates/clips/chrome-extension/src/popup.ts +30 -21
  10. package/corpus/templates/content/CHANGELOG.md +7 -0
  11. package/corpus/templates/content/actions/resolve-preview-document-draft.ts +181 -16
  12. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +28 -15
  13. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +11 -0
  14. package/corpus/templates/content/app/components/editor/document-save-rebase.ts +13 -10
  15. package/corpus/templates/content/app/components/editor/useDocumentReconcileRecovery.ts +80 -0
  16. package/corpus/templates/content/changelog/2026-09-16-recovery-choices-stay-attached-to-the-draft-you-reviewed.md +6 -0
  17. package/corpus/templates/design/CHANGELOG.md +29 -0
  18. package/corpus/templates/design/actions/import-design-source.ts +42 -2
  19. package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +26 -15
  20. package/corpus/templates/design/app/components/design/edit-panel/code-inspect-helpers.tsx +83 -1
  21. package/corpus/templates/design/app/i18n-data.ts +3 -1
  22. package/corpus/templates/design/app/lib/design-file-upload.ts +6 -4
  23. package/corpus/templates/design/app/lib/design-import.ts +2 -0
  24. package/corpus/templates/design/app/lib/fig-client-import.ts +207 -49
  25. package/corpus/templates/design/changelog/2026-09-17-large-fig-files-import-directly-in-the-browser-without-the-o.md +6 -0
  26. package/corpus/templates/design/server/lib/fig-file-decoder.ts +31 -8
  27. package/corpus/templates/design/server/lib/import-design-files.ts +188 -75
  28. package/corpus/templates/design/server/plugins/db.ts +16 -7
  29. package/corpus/templates/design/shared/fig-to-frames.ts +55 -25
  30. package/corpus/templates/factory/CHANGELOG.md +6 -0
  31. package/corpus/templates/mail/CHANGELOG.md +6 -0
  32. package/corpus/templates/slides/CHANGELOG.md +12 -0
  33. package/corpus/templates/slides/app/components/editor/AssetLibraryPanel.tsx +1 -1
  34. package/corpus/templates/slides/app/components/editor/ImageDropPromptPopover.tsx +1 -2
  35. package/corpus/templates/slides/app/hooks/use-agent-generating.ts +82 -2
  36. package/corpus/templates/slides/app/lib/image-drop-to-agent.ts +9 -2
  37. package/corpus/templates/slides/app/lib/slide-image-replacement.ts +1 -1
  38. package/corpus/templates/slides/app/pages/DeckEditor.tsx +1 -1
  39. package/corpus/templates/slides/server/handlers/assets.ts +134 -8
  40. package/corpus/templates/slides/server/handlers/uploads.ts +23 -10
  41. package/corpus/templates/slides/shared/upload-types.ts +1 -0
  42. package/dist/application-state/store.d.ts +9 -0
  43. package/dist/application-state/store.js +18 -0
  44. package/dist/automation/index.d.ts +1 -1
  45. package/dist/collab/struct-routes.d.ts +1 -1
  46. package/dist/deploy/build.js +7 -0
  47. package/dist/file-upload/actions/upload-image.d.ts +41 -10
  48. package/dist/file-upload/actions/upload-image.js +399 -19
  49. package/dist/jobs/scheduler.js +11 -0
  50. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  51. package/dist/provider-api/actions/custom-provider-registration.d.ts +7 -7
  52. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  53. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  54. package/dist/resources/handlers.d.ts +1 -1
  55. package/dist/secrets/routes.d.ts +3 -3
  56. package/package.json +4 -4
@@ -4,8 +4,8 @@ import {
4
4
  hasCollabState,
5
5
  seedFromText,
6
6
  } from "@agent-native/core/collab";
7
- import { assertAccess } from "@agent-native/core/sharing";
8
- import { eq } from "drizzle-orm";
7
+ import { assertAccess, resolveAccess } from "@agent-native/core/sharing";
8
+ import { and, eq } from "drizzle-orm";
9
9
  import { nanoid } from "nanoid";
10
10
 
11
11
  import {
@@ -25,6 +25,8 @@ export interface ImportedDesignFile {
25
25
  filename: string;
26
26
  fileType: "html" | "css" | "jsx" | "asset";
27
27
  content: string;
28
+ /** Stable retry marker for a browser import request. */
29
+ operationSource?: string;
28
30
  source?: Record<string, unknown>;
29
31
  preferredFrame?: {
30
32
  title?: string;
@@ -50,6 +52,53 @@ export interface SavedImportedDesignFile {
50
52
  source?: Record<string, unknown>;
51
53
  }
52
54
 
55
+ export async function findImportedDesignFileByOperationSource(
56
+ designId: string,
57
+ operationSource: string,
58
+ ): Promise<{
59
+ file: SavedImportedDesignFile;
60
+ placed: boolean;
61
+ } | null> {
62
+ const access = await resolveAccess("design", designId);
63
+ if (!access) return null;
64
+ const db = getDb();
65
+ const [file] = await db
66
+ .select()
67
+ .from(schema.designFiles)
68
+ .where(
69
+ and(
70
+ eq(schema.designFiles.designId, designId),
71
+ eq(schema.designFiles.contentOperationSource, operationSource),
72
+ ),
73
+ )
74
+ .limit(1);
75
+ if (!file) return null;
76
+
77
+ let metadata: Record<string, unknown> | undefined;
78
+ try {
79
+ const parsed = access.resource.data
80
+ ? JSON.parse(access.resource.data)
81
+ : null;
82
+ const screenMetadata = isRecord(parsed) ? parsed.screenMetadata : null;
83
+ const candidate = isRecord(screenMetadata)
84
+ ? screenMetadata[file.id]
85
+ : undefined;
86
+ metadata = isRecord(candidate) ? candidate : undefined;
87
+ } catch {
88
+ metadata = undefined;
89
+ }
90
+
91
+ return {
92
+ file: {
93
+ id: file.id,
94
+ filename: file.filename,
95
+ fileType: file.fileType,
96
+ source: metadata,
97
+ },
98
+ placed: metadata?.operationSource === operationSource,
99
+ };
100
+ }
101
+
53
102
  function isRecord(value: unknown): value is Record<string, unknown> {
54
103
  return value !== null && typeof value === "object" && !Array.isArray(value);
55
104
  }
@@ -58,6 +107,12 @@ function jsonValuesEqual(left: unknown, right: unknown): boolean {
58
107
  return JSON.stringify(left) === JSON.stringify(right);
59
108
  }
60
109
 
110
+ function isOperationSourceUniqueViolation(error: unknown): boolean {
111
+ return (error instanceof Error ? error.message : String(error)).includes(
112
+ "design_files_design_operation_source_unique_idx",
113
+ );
114
+ }
115
+
61
116
  function nextImportedFrameZ(currentCanvasFrames: unknown): number {
62
117
  const currentFrames = parseCanvasFrameGeometryById(currentCanvasFrames);
63
118
  const currentFrameEntries = Object.values(currentFrames);
@@ -75,6 +130,20 @@ function nextImportedFrameZ(currentCanvasFrames: unknown): number {
75
130
  return Math.max(currentFrameEntries.length, highestPersistedZ + 1);
76
131
  }
77
132
 
133
+ function nextImportedFrameX(currentCanvasFrames: unknown): number {
134
+ const currentFrames = Object.values(
135
+ parseCanvasFrameGeometryById(currentCanvasFrames),
136
+ );
137
+ const right = currentFrames.reduce((maxRight, frame) => {
138
+ const x = frame.x ?? 0;
139
+ const width = frame.width ?? 0;
140
+ return Number.isFinite(x) && Number.isFinite(width)
141
+ ? Math.max(maxRight, x + width)
142
+ : maxRight;
143
+ }, 0);
144
+ return currentFrames.length > 0 ? right + FRAME_GAP : 0;
145
+ }
146
+
78
147
  function stringFromState(value: unknown, key: string): string | undefined {
79
148
  return isRecord(value) && typeof value[key] === "string"
80
149
  ? (value[key] as string)
@@ -228,87 +297,131 @@ export async function saveImportedDesignFiles(
228
297
  isApplied: () => true,
229
298
  });
230
299
 
231
- await db.transaction(async (tx) => {
232
- const [design] = await tx
233
- .select()
234
- .from(schema.designs)
235
- .where(eq(schema.designs.id, designId))
236
- .limit(1);
237
- if (!design) throw new Error(`Design ${designId} was not found.`);
300
+ try {
301
+ await db.transaction(async (tx) => {
302
+ const [design] = await tx
303
+ .select()
304
+ .from(schema.designs)
305
+ .where(eq(schema.designs.id, designId))
306
+ .limit(1);
307
+ if (!design) throw new Error(`Design ${designId} was not found.`);
238
308
 
239
- const existingFiles = await tx
240
- .select()
241
- .from(schema.designFiles)
242
- .where(eq(schema.designFiles.designId, designId));
243
- const usedFilenames = new Set(existingFiles.map((file) => file.filename));
244
- let nextFrameX = 0;
309
+ const existingFiles = await tx
310
+ .select()
311
+ .from(schema.designFiles)
312
+ .where(eq(schema.designFiles.designId, designId));
313
+ const usedFilenames = new Set(existingFiles.map((file) => file.filename));
245
314
 
246
- for (let index = 0; index < input.files.length; index += 1) {
247
- const file = input.files[index]!;
248
- const filename = uniqueFilename(
249
- ensureExtension(sanitizeImportedFilename(file.filename), file.fileType),
250
- usedFilenames,
251
- );
252
- const fileId = nanoid();
253
- // Exact native clones are immutable source evidence. Other imports are
254
- // annotated before persistence so editor operations can address nodes.
255
- const annotatedContent = input.preserveExactContent
256
- ? file.content
257
- : annotateScreenHtmlForPersist(file.content, file.fileType);
258
- await tx.insert(schema.designFiles).values({
259
- id: fileId,
260
- designId,
261
- filename,
262
- fileType: file.fileType,
263
- content: annotatedContent,
264
- createdAt: now,
265
- updatedAt: now,
266
- });
267
- seedRecords.push({ id: fileId, content: annotatedContent });
315
+ for (let index = 0; index < input.files.length; index += 1) {
316
+ const file = input.files[index]!;
317
+ const [existing] = file.operationSource
318
+ ? await tx
319
+ .select()
320
+ .from(schema.designFiles)
321
+ .where(
322
+ and(
323
+ eq(schema.designFiles.designId, designId),
324
+ eq(
325
+ schema.designFiles.contentOperationSource,
326
+ file.operationSource,
327
+ ),
328
+ ),
329
+ )
330
+ .limit(1)
331
+ : [];
332
+ const filename =
333
+ existing?.filename ??
334
+ uniqueFilename(
335
+ ensureExtension(
336
+ sanitizeImportedFilename(file.filename),
337
+ file.fileType,
338
+ ),
339
+ usedFilenames,
340
+ );
341
+ const fileId = existing?.id ?? nanoid();
342
+ // Exact native clones are immutable source evidence. Other imports are
343
+ // annotated before persistence so editor operations can address nodes.
344
+ const annotatedContent = input.preserveExactContent
345
+ ? file.content
346
+ : annotateScreenHtmlForPersist(file.content, file.fileType);
347
+ if (!existing) {
348
+ await tx.insert(schema.designFiles).values({
349
+ id: fileId,
350
+ designId,
351
+ filename,
352
+ fileType: file.fileType,
353
+ content: annotatedContent,
354
+ contentOperationSource: file.operationSource ?? null,
355
+ createdAt: now,
356
+ updatedAt: now,
357
+ });
358
+ seedRecords.push({ id: fileId, content: annotatedContent });
359
+ } else {
360
+ seedRecords.push({ id: fileId, content: existing.content });
361
+ }
268
362
 
269
- const width = positiveDimension(
270
- file.preferredFrame?.width,
271
- DEFAULT_FRAME_WIDTH,
272
- );
273
- const height = positiveDimension(
274
- file.preferredFrame?.height,
275
- DEFAULT_FRAME_HEIGHT,
276
- );
277
- placements.push({
278
- fileId,
279
- filename,
280
- x: file.preferredFrame?.x ?? nextFrameX,
281
- y: file.preferredFrame?.y ?? 0,
282
- width,
283
- height,
284
- z: index,
285
- });
286
- nextFrameX += width + FRAME_GAP;
287
- const source = {
288
- sourceType: input.sourceType,
289
- previewState: "static",
290
- title: file.preferredFrame?.title ?? filename.replace(/\.[^.]+$/, ""),
291
- width,
292
- height,
293
- ...file.source,
294
- };
295
- metadataByFileId.set(fileId, source);
296
- savedFiles.push({
297
- id: fileId,
298
- filename,
299
- fileType: file.fileType,
300
- source,
301
- });
363
+ const width = positiveDimension(
364
+ file.preferredFrame?.width,
365
+ DEFAULT_FRAME_WIDTH,
366
+ );
367
+ const height = positiveDimension(
368
+ file.preferredFrame?.height,
369
+ DEFAULT_FRAME_HEIGHT,
370
+ );
371
+ placements.push({
372
+ fileId,
373
+ filename,
374
+ ...(file.preferredFrame?.x !== undefined
375
+ ? { x: file.preferredFrame.x }
376
+ : {}),
377
+ y: file.preferredFrame?.y ?? 0,
378
+ width,
379
+ height,
380
+ z: index,
381
+ });
382
+ const source = {
383
+ sourceType: input.sourceType,
384
+ previewState: "static",
385
+ title: file.preferredFrame?.title ?? filename.replace(/\.[^.]+$/, ""),
386
+ width,
387
+ height,
388
+ ...file.source,
389
+ };
390
+ metadataByFileId.set(fileId, source);
391
+ savedFiles.push({
392
+ id: fileId,
393
+ filename,
394
+ fileType: file.fileType,
395
+ source,
396
+ });
397
+ }
398
+ });
399
+ } catch (error) {
400
+ if (
401
+ input.files.some((file) => file.operationSource) &&
402
+ isOperationSourceUniqueViolation(error)
403
+ ) {
404
+ return saveImportedDesignFiles(input);
302
405
  }
303
- });
406
+ throw error;
407
+ }
304
408
 
305
409
  await mutateDesignData({
306
410
  designId,
307
411
  mutate: (current, { updatedAt }) => {
308
- placementsForPersistence = placements.map((placement, index) => ({
309
- ...placement,
310
- z: nextImportedFrameZ(current.canvasFrames) + index,
311
- }));
412
+ let nextFrameX = nextImportedFrameX(current.canvasFrames);
413
+ placementsForPersistence = placements.map((placement, index) => {
414
+ const x = placement.x ?? nextFrameX;
415
+ nextFrameX = Math.max(
416
+ nextFrameX,
417
+ x + (placement.width ?? 0) + FRAME_GAP,
418
+ );
419
+ return {
420
+ ...placement,
421
+ x,
422
+ z: nextImportedFrameZ(current.canvasFrames) + index,
423
+ };
424
+ });
312
425
  const previousMetadata = isRecord(current.screenMetadata)
313
426
  ? { ...current.screenMetadata }
314
427
  : {};
@@ -419,13 +419,10 @@ CREATE INDEX IF NOT EXISTS design_versions_design_created_idx ON design_versions
419
419
  * swallowed so it can never fail boot.
420
420
  */
421
421
  /**
422
- * Best-effort unique index guarding against the add-localhost-screens
423
- * cross-request race: two concurrent calls placing the same localhost route
424
- * each see "no existing design_files row" from their own snapshot and both
425
- * insert a fresh row using the same deterministic filename for that route
426
- * (see actions/add-localhost-screens.ts). A unique index on
427
- * (design_id, filename) makes the losing insert fail instead of silently
428
- * creating an overlapping duplicate screen.
422
+ * Best-effort unique indexes guarding cross-request insert races. Two
423
+ * concurrent calls can each see "no existing design_files row" from their
424
+ * own snapshot. The filename index protects localhost placement; the
425
+ * operation-source index protects retryable browser imports.
429
426
  *
430
427
  * This intentionally does NOT live in `runDesignMigrations` above:
431
428
  * `CREATE UNIQUE INDEX` fails outright against any database that already
@@ -456,6 +453,18 @@ async function ensureDesignFilesUniqueIndex(): Promise<void> {
456
453
  err instanceof Error ? err.message : err,
457
454
  );
458
455
  }
456
+ try {
457
+ await getDbExec().execute(
458
+ `CREATE UNIQUE INDEX IF NOT EXISTS design_files_design_operation_source_unique_idx ON design_files (design_id, content_operation_source) WHERE content_operation_source LIKE 'fig-import:%'`,
459
+ );
460
+ } catch (err) {
461
+ console.warn(
462
+ "[db] design_files_design_operation_source_unique_idx not created — " +
463
+ "pre-existing duplicate operation sources may require cleanup before " +
464
+ "concurrent browser-import retries are fully serialized:",
465
+ err instanceof Error ? err.message : err,
466
+ );
467
+ }
459
468
  }
460
469
 
461
470
  export default async (nitroApp: any): Promise<void> => {
@@ -26,6 +26,8 @@ const MAX_FIG_NODES = 75_000;
26
26
  const MAX_FIG_IMAGES = 1_024;
27
27
  const MAX_FIG_FRAMES = 200;
28
28
  const MAX_FRAME_HTML_BYTES = 4 * 1024 * 1024;
29
+ /** The browser action transport accepts smaller per-frame payloads. */
30
+ export const MAX_FIG_FRAME_HTML_BYTES = 2 * 1024 * 1024;
29
31
  const MAX_TOTAL_HTML_BYTES = 24 * 1024 * 1024;
30
32
  const MAX_EMBEDDED_IMAGE_BYTES = 64 * 1024 * 1024;
31
33
  const IMAGE_UPLOAD_CONCURRENCY = 4;
@@ -34,6 +36,8 @@ const MAX_DURABLE_IMAGE_URL_CHARS = 2_048;
34
36
  export interface FigFileImportResult {
35
37
  files: ImportedDesignFile[];
36
38
  warnings: string[];
39
+ cleanup?: () => Promise<number>;
40
+ finalize?: () => Promise<number>;
37
41
  stats: {
38
42
  sourceKind: "fig-upload";
39
43
  format: "kiwi" | "zip";
@@ -78,7 +82,11 @@ export type ImageUploader = (input: {
78
82
  ownerEmail: string;
79
83
  recordAsset?: boolean;
80
84
  stableUrl?: boolean;
81
- }) => Promise<{ url?: string; cleanup?: () => Promise<boolean> } | null>;
85
+ }) => Promise<{
86
+ url?: string;
87
+ cleanup?: () => Promise<boolean>;
88
+ finalize?: () => Promise<boolean>;
89
+ } | null>;
82
90
 
83
91
  /** Wraps a frame's HTML into a standalone document. */
84
92
  export type HtmlNormalizer = (content: string, sourceLabel: string) => string;
@@ -182,6 +190,7 @@ async function uploadEmbeddedImages(
182
190
  omitted: number;
183
191
  warnings: string[];
184
192
  cleanup: () => Promise<number>;
193
+ finalize: () => Promise<number>;
185
194
  }> {
186
195
  assertEmbeddedImageBudget(images);
187
196
 
@@ -201,6 +210,7 @@ async function uploadEmbeddedImages(
201
210
  let omitted = 0;
202
211
  let storageUnavailable = false;
203
212
  const uploadCleanups: Array<() => Promise<boolean>> = [];
213
+ const uploadFinalizers: Array<() => Promise<boolean>> = [];
204
214
 
205
215
  for (
206
216
  let offset = 0;
@@ -224,6 +234,7 @@ async function uploadEmbeddedImages(
224
234
  stableUrl: true,
225
235
  });
226
236
  if (uploaded?.cleanup) uploadCleanups.push(uploaded.cleanup);
237
+ if (uploaded?.finalize) uploadFinalizers.push(uploaded.finalize);
227
238
  if (!uploaded?.url) {
228
239
  storageUnavailable = true;
229
240
  omitted += 1;
@@ -243,12 +254,6 @@ async function uploadEmbeddedImages(
243
254
  );
244
255
  }
245
256
 
246
- if (omitted > 0) {
247
- const message = `${omitted} embedded image${omitted === 1 ? " was" : "s were"} omitted because file storage was unavailable or rejected the upload. No image bytes were stored in SQL.`;
248
- const cleanupFailures = await cleanupUploadedImages(uploadCleanups);
249
- throw new Error(withCleanupFailures(message, cleanupFailures));
250
- }
251
-
252
257
  return {
253
258
  imageMap,
254
259
  imageSizes,
@@ -256,6 +261,7 @@ async function uploadEmbeddedImages(
256
261
  omitted,
257
262
  warnings: [],
258
263
  cleanup: () => cleanupUploadedImages(uploadCleanups),
264
+ finalize: () => finalizeUploadedImages(uploadFinalizers),
259
265
  };
260
266
  }
261
267
 
@@ -270,12 +276,23 @@ async function cleanupUploadedImages(
270
276
  ).length;
271
277
  }
272
278
 
279
+ async function finalizeUploadedImages(
280
+ finalizers: Array<() => Promise<boolean>>,
281
+ ): Promise<number> {
282
+ const results = await Promise.allSettled(
283
+ finalizers.map((finalize) => finalize()),
284
+ );
285
+ return results.filter(
286
+ (result) => result.status === "rejected" || !result.value,
287
+ ).length;
288
+ }
289
+
273
290
  function withCleanupFailures(message: string, failures: number): string {
274
291
  if (failures === 0) return message;
275
292
  return `${message} Storage cleanup failed for ${failures} uploaded image${failures === 1 ? "" : "s"}.`;
276
293
  }
277
294
 
278
- function assertEmbeddedImageBudget(images: DecodedFigImage[]): void {
295
+ export function assertEmbeddedImageBudget(images: DecodedFigImage[]): void {
279
296
  if (images.length > MAX_FIG_IMAGES) {
280
297
  throw new Error(".fig document has too many embedded images (max 1,024).");
281
298
  }
@@ -297,9 +314,11 @@ export async function convertDecodedFigToEditableHtml(
297
314
  ownerEmail: string;
298
315
  uploader: ImageUploader;
299
316
  normalizeHtml: HtmlNormalizer;
317
+ maxFrameHtmlBytes?: number;
300
318
  selection?: ReadonlySet<string>;
301
319
  },
302
320
  ): Promise<FigFileImportResult> {
321
+ const maxFrameHtmlBytes = options.maxFrameHtmlBytes ?? MAX_FRAME_HTML_BYTES;
303
322
  assertSafeDecodedFigDocument(decoded.document);
304
323
  const nodeChanges = nodeChangesFromDocument(
305
324
  decoded.document,
@@ -325,29 +344,36 @@ export async function convertDecodedFigToEditableHtml(
325
344
  trackUnresolvedImageRefs: true,
326
345
  selection,
327
346
  });
328
- validateRenderedFrames(preliminary);
347
+ validateRenderedFrames(preliminary, maxFrameHtmlBytes);
329
348
  const imagesToUpload = selection
330
349
  ? decoded.images.filter((image) =>
331
350
  preliminary.unresolvedImageRefs?.has(image.hash),
332
351
  )
333
352
  : decoded.images;
334
- const images = await uploadEmbeddedImages(
335
- imagesToUpload,
336
- options.ownerEmail,
337
- options.uploader,
338
- );
353
+ let images: Awaited<ReturnType<typeof uploadEmbeddedImages>> | undefined;
339
354
  try {
355
+ const uploadedImages = await uploadEmbeddedImages(
356
+ imagesToUpload,
357
+ options.ownerEmail,
358
+ options.uploader,
359
+ );
360
+ images = uploadedImages;
361
+ if (uploadedImages.omitted > 0) {
362
+ throw new Error(
363
+ `${uploadedImages.omitted} embedded image${uploadedImages.omitted === 1 ? " was" : "s were"} omitted because file storage was unavailable or rejected the upload. No image bytes were stored in SQL.`,
364
+ );
365
+ }
340
366
  const rendered =
341
367
  imagesToUpload.length === 0
342
368
  ? preliminary
343
369
  : renderHtmlTemplates(decoded.document, {
344
- imageMap: images.imageMap,
345
- imageSizes: images.imageSizes,
370
+ imageMap: uploadedImages.imageMap,
371
+ imageSizes: uploadedImages.imageSizes,
346
372
  missingImageUrl: "about:blank",
347
373
  trackUnresolvedImageRefs: true,
348
374
  selection,
349
375
  });
350
- validateRenderedFrames(rendered);
376
+ validateRenderedFrames(rendered, maxFrameHtmlBytes);
351
377
 
352
378
  let totalHtmlBytes = 0;
353
379
  const files = rendered.frames.map((frame) => {
@@ -356,9 +382,9 @@ export async function convertDecodedFigToEditableHtml(
356
382
  `experimental .fig upload ${options.originalName}`,
357
383
  );
358
384
  const htmlBytes = utf8ByteLength(content);
359
- if (htmlBytes > MAX_FRAME_HTML_BYTES) {
385
+ if (htmlBytes > maxFrameHtmlBytes) {
360
386
  throw new Error(
361
- `.fig frame "${frame.frameName}" is too complex (generated HTML exceeds 4 MB).`,
387
+ `.fig frame "${frame.frameName}" is too complex (generated HTML exceeds ${Math.round(maxFrameHtmlBytes / 1024 / 1024)} MB).`,
362
388
  );
363
389
  }
364
390
  totalHtmlBytes += htmlBytes;
@@ -390,7 +416,9 @@ export async function convertDecodedFigToEditableHtml(
390
416
 
391
417
  return {
392
418
  files,
393
- warnings: images.warnings,
419
+ warnings: uploadedImages.warnings,
420
+ cleanup: uploadedImages.cleanup,
421
+ finalize: uploadedImages.finalize,
394
422
  stats: {
395
423
  sourceKind: "fig-upload",
396
424
  format: decoded.format,
@@ -399,14 +427,14 @@ export async function convertDecodedFigToEditableHtml(
399
427
  frameCount: rendered.frameCount,
400
428
  nodeCount: nodeChanges.length,
401
429
  imageCount: decoded.images.length,
402
- uploadedImageCount: images.uploaded,
403
- omittedImageCount: images.omitted,
430
+ uploadedImageCount: uploadedImages.uploaded,
431
+ omittedImageCount: uploadedImages.omitted,
404
432
  approximatedNodeCount: rendered.approximatedNodes.length,
405
433
  unresolvedImageRefCount: rendered.unresolvedImageRefs?.size ?? 0,
406
434
  },
407
435
  };
408
436
  } catch (error) {
409
- const cleanupFailures = await images.cleanup();
437
+ const cleanupFailures = images ? await images.cleanup() : 0;
410
438
  if (cleanupFailures === 0) throw error;
411
439
  const message = error instanceof Error ? error.message : String(error);
412
440
  throw new Error(withCleanupFailures(message, cleanupFailures));
@@ -415,6 +443,7 @@ export async function convertDecodedFigToEditableHtml(
415
443
 
416
444
  function validateRenderedFrames(
417
445
  rendered: ReturnType<typeof renderHtmlTemplates>,
446
+ maxFrameHtmlBytes = MAX_FRAME_HTML_BYTES,
418
447
  ): void {
419
448
  if (rendered.frames.length === 0) {
420
449
  throw new Error(
@@ -427,9 +456,9 @@ function validateRenderedFrames(
427
456
  let total = 0;
428
457
  for (const frame of rendered.frames) {
429
458
  const bytes = utf8ByteLength(frame.html);
430
- if (bytes > MAX_FRAME_HTML_BYTES) {
459
+ if (bytes > maxFrameHtmlBytes) {
431
460
  throw new Error(
432
- `.fig frame "${frame.frameName}" is too complex (generated HTML exceeds 4 MB).`,
461
+ `.fig frame "${frame.frameName}" is too complex (generated HTML exceeds ${Math.round(maxFrameHtmlBytes / 1024 / 1024)} MB).`,
433
462
  );
434
463
  }
435
464
  total += bytes;
@@ -447,6 +476,7 @@ export async function importFigFileToEditableHtml(options: {
447
476
  ownerEmail: string;
448
477
  uploader: ImageUploader;
449
478
  normalizeHtml: HtmlNormalizer;
479
+ maxFrameHtmlBytes?: number;
450
480
  }): Promise<FigFileImportResult> {
451
481
  const decoded = decodeFig(options.data);
452
482
  return convertDecodedFigToEditableHtml(decoded, options);
@@ -3,6 +3,12 @@
3
3
  All notable user-facing changes to Chat are documented here. Open it any
4
4
  time from the command menu (Cmd+K → "What's new").
5
5
 
6
+ ## 2026-09-15
7
+
8
+ ### Fixed
9
+
10
+ - Factory automation prompts version on save, show skill alignment separately, and stop duplicating injected blocks.
11
+
6
12
  ## 2026-09-14
7
13
 
8
14
  ### Improved
@@ -3,6 +3,12 @@
3
3
  All notable user-facing changes to Agent-Native Mail are documented here. Open it any
4
4
  time from the command menu (Cmd+K → "What's new") or from Settings.
5
5
 
6
+ ## 2026-09-17
7
+
8
+ ### Fixed
9
+
10
+ - Mail undo keeps newer thread actions intact
11
+
6
12
  ## 2026-09-16
7
13
 
8
14
  ### Fixed
@@ -7,8 +7,20 @@ time from the command menu (Cmd+K → "What's new").
7
7
 
8
8
  ### Fixed
9
9
 
10
+ - Allow keyboard nudges to move multiple selected slide layers together
11
+ - Dragging slide elements keeps selection handles aligned and snap guides on the visual edges
12
+ - Duplicating slides saves successfully when speaker notes are missing
13
+ - Editing slide text preserves its formatting, layout, line breaks, and rich clipboard styles
14
+ - Keep layout wrappers selectable around text blocks in Slides
15
+ - Moving a multi-selection keeps bordered slide cards together
16
+ - Slide content edits reject regex patterns that could freeze the editor
17
+ - Slides keeps chat turns in order and restores the current slide context when you navigate or select.
10
18
  - Content-only agent edits now preserve the slide's existing backgrounds and styling.
11
19
 
20
+ ### Changed
21
+
22
+ - Imported decks now support adding, deleting, and reordering slides
23
+
12
24
  ## 2026-09-15
13
25
 
14
26
  ### Improved
@@ -187,7 +187,7 @@ export default function AssetLibraryPanel({
187
187
  </span>
188
188
  <input
189
189
  type="file"
190
- accept="image/*"
190
+ accept="image/*,.svg"
191
191
  multiple
192
192
  onChange={handleUpload}
193
193
  className="hidden"
@@ -127,8 +127,7 @@ export default function ImageDropPromptPopover({
127
127
  const form = new FormData();
128
128
  form.append("file", file);
129
129
  // Prefer the hosted provider chain. When none is configured the route
130
- // returns 503 — fall back to an inline data URL so the agent still gets
131
- // the image (chat already accepts `images` data URLs).
130
+ // returns 503 — fall back to an inline data URL for inline-safe images.
132
131
  const res = await fetch(`${appBasePath()}/api/assets/upload`, {
133
132
  method: "POST",
134
133
  body: form,