@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/CHANGELOG.md +6 -0
- package/corpus/templates/calendar/CHANGELOG.md +1 -0
- package/corpus/templates/clips/CHANGELOG.md +7 -0
- package/corpus/templates/clips/app/components/library/sort-menu.tsx +2 -2
- package/corpus/templates/clips/changelog/2026-09-17-clips-waits-for-authentication-before-showing-the-recording-.md +6 -0
- package/corpus/templates/clips/changelog/2026-09-17-the-clips-sort-control-keeps-its-keyboard-focus-ring-visible.md +6 -0
- package/corpus/templates/clips/chrome-extension/src/popup.html +1 -1
- package/corpus/templates/clips/chrome-extension/src/popup.ts +30 -21
- package/corpus/templates/content/CHANGELOG.md +7 -0
- package/corpus/templates/content/actions/resolve-preview-document-draft.ts +181 -16
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +28 -15
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +11 -0
- package/corpus/templates/content/app/components/editor/document-save-rebase.ts +13 -10
- package/corpus/templates/content/app/components/editor/useDocumentReconcileRecovery.ts +80 -0
- package/corpus/templates/content/changelog/2026-09-16-recovery-choices-stay-attached-to-the-draft-you-reviewed.md +6 -0
- package/corpus/templates/design/CHANGELOG.md +29 -0
- package/corpus/templates/design/actions/import-design-source.ts +42 -2
- package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +26 -15
- package/corpus/templates/design/app/components/design/edit-panel/code-inspect-helpers.tsx +83 -1
- package/corpus/templates/design/app/i18n-data.ts +3 -1
- package/corpus/templates/design/app/lib/design-file-upload.ts +6 -4
- package/corpus/templates/design/app/lib/design-import.ts +2 -0
- package/corpus/templates/design/app/lib/fig-client-import.ts +207 -49
- package/corpus/templates/design/changelog/2026-09-17-large-fig-files-import-directly-in-the-browser-without-the-o.md +6 -0
- package/corpus/templates/design/server/lib/fig-file-decoder.ts +31 -8
- package/corpus/templates/design/server/lib/import-design-files.ts +188 -75
- package/corpus/templates/design/server/plugins/db.ts +16 -7
- package/corpus/templates/design/shared/fig-to-frames.ts +55 -25
- package/corpus/templates/factory/CHANGELOG.md +6 -0
- package/corpus/templates/mail/CHANGELOG.md +6 -0
- package/corpus/templates/slides/CHANGELOG.md +12 -0
- package/corpus/templates/slides/app/components/editor/AssetLibraryPanel.tsx +1 -1
- package/corpus/templates/slides/app/components/editor/ImageDropPromptPopover.tsx +1 -2
- package/corpus/templates/slides/app/hooks/use-agent-generating.ts +82 -2
- package/corpus/templates/slides/app/lib/image-drop-to-agent.ts +9 -2
- package/corpus/templates/slides/app/lib/slide-image-replacement.ts +1 -1
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +1 -1
- package/corpus/templates/slides/server/handlers/assets.ts +134 -8
- package/corpus/templates/slides/server/handlers/uploads.ts +23 -10
- package/corpus/templates/slides/shared/upload-types.ts +1 -0
- package/dist/application-state/store.d.ts +9 -0
- package/dist/application-state/store.js +18 -0
- package/dist/automation/index.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/deploy/build.js +7 -0
- package/dist/file-upload/actions/upload-image.d.ts +41 -10
- package/dist/file-upload/actions/upload-image.js +399 -19
- package/dist/jobs/scheduler.js +11 -0
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/provider-api/actions/custom-provider-registration.d.ts +7 -7
- package/dist/provider-api/actions/provider-api.d.ts +11 -11
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- 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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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<{
|
|
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
|
-
|
|
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:
|
|
345
|
-
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 >
|
|
385
|
+
if (htmlBytes > maxFrameHtmlBytes) {
|
|
360
386
|
throw new Error(
|
|
361
|
-
`.fig frame "${frame.frameName}" is too complex (generated HTML exceeds
|
|
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:
|
|
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:
|
|
403
|
-
omittedImageCount:
|
|
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 >
|
|
459
|
+
if (bytes > maxFrameHtmlBytes) {
|
|
431
460
|
throw new Error(
|
|
432
|
-
`.fig frame "${frame.frameName}" is too complex (generated HTML exceeds
|
|
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
|
|
@@ -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
|
|
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,
|