@agent-native/core 0.84.14 → 0.84.17

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 (58) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +23 -0
  3. package/corpus/core/package.json +2 -2
  4. package/corpus/core/src/agent/durable-background.ts +6 -0
  5. package/corpus/core/src/agent/engine/builder-engine.ts +7 -3
  6. package/corpus/core/src/agent/production-agent.ts +10 -3
  7. package/corpus/core/src/client/AssistantChat.tsx +30 -4
  8. package/corpus/core/src/client/MultiTabAssistantChat.tsx +11 -3
  9. package/corpus/core/src/client/agent-chat.ts +18 -3
  10. package/corpus/core/src/client/use-agent-engine-configured.ts +7 -4
  11. package/corpus/core/src/db/client.ts +10 -1
  12. package/corpus/core/src/server/agent-chat-plugin.ts +69 -43
  13. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/DashboardFilterBar.tsx +18 -24
  14. package/corpus/templates/analytics/changelog/2026-07-01-dashboard-charts-no-longer-show-future-dates-when-clients-se.md +6 -0
  15. package/corpus/templates/analytics/changelog/2026-07-01-dashboard-filters-take-less-space-and-keep-view-actions-tucked.md +6 -0
  16. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +34 -9
  17. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +6 -2
  18. package/corpus/templates/analytics/server/lib/session-replay.ts +109 -50
  19. package/corpus/templates/analytics/server/plugins/db.ts +21 -0
  20. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  21. package/corpus/templates/design/app/pages/DesignEditor.tsx +176 -13
  22. package/corpus/templates/design/changelog/2026-07-01-exporting-a-design-as-png-or-svg-now-downloads-just-the-sele.md +6 -0
  23. package/dist/agent/durable-background.d.ts +1 -0
  24. package/dist/agent/durable-background.d.ts.map +1 -1
  25. package/dist/agent/durable-background.js +3 -0
  26. package/dist/agent/durable-background.js.map +1 -1
  27. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  28. package/dist/agent/engine/builder-engine.js +7 -1
  29. package/dist/agent/engine/builder-engine.js.map +1 -1
  30. package/dist/agent/production-agent.d.ts.map +1 -1
  31. package/dist/agent/production-agent.js +6 -4
  32. package/dist/agent/production-agent.js.map +1 -1
  33. package/dist/client/AssistantChat.d.ts +8 -2
  34. package/dist/client/AssistantChat.d.ts.map +1 -1
  35. package/dist/client/AssistantChat.js +18 -2
  36. package/dist/client/AssistantChat.js.map +1 -1
  37. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  38. package/dist/client/MultiTabAssistantChat.js +7 -3
  39. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  40. package/dist/client/agent-chat.d.ts +7 -0
  41. package/dist/client/agent-chat.d.ts.map +1 -1
  42. package/dist/client/agent-chat.js +5 -1
  43. package/dist/client/agent-chat.js.map +1 -1
  44. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  45. package/dist/client/use-agent-engine-configured.js +7 -1
  46. package/dist/client/use-agent-engine-configured.js.map +1 -1
  47. package/dist/collab/awareness.d.ts +2 -2
  48. package/dist/collab/awareness.d.ts.map +1 -1
  49. package/dist/db/client.d.ts +6 -0
  50. package/dist/db/client.d.ts.map +1 -1
  51. package/dist/db/client.js +8 -1
  52. package/dist/db/client.js.map +1 -1
  53. package/dist/notifications/routes.d.ts +3 -3
  54. package/dist/observability/routes.d.ts +2 -2
  55. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  56. package/dist/server/agent-chat-plugin.js +57 -40
  57. package/dist/server/agent-chat-plugin.js.map +1 -1
  58. package/package.json +2 -2
@@ -275,6 +275,53 @@ function replayMaxIso(values: Array<string | null | undefined>): string | null {
275
275
  return present.reduce((max, value) => (value > max ? value : max));
276
276
  }
277
277
 
278
+ function replayClampIso(
279
+ value: string | null | undefined,
280
+ latestIso: string,
281
+ ): string | null {
282
+ if (!value) return null;
283
+ const parsed = Date.parse(value);
284
+ const latest = Date.parse(latestIso);
285
+ if (!Number.isFinite(parsed)) return null;
286
+ if (!Number.isFinite(latest)) return value;
287
+ return parsed > latest ? latestIso : value;
288
+ }
289
+
290
+ function clampReplayChunkTiming(
291
+ chunk: NormalizedSessionReplayChunk,
292
+ latestIso: string,
293
+ ): NormalizedSessionReplayChunk {
294
+ const startedAt = replayClampIso(chunk.startedAt, latestIso);
295
+ let endedAt = replayClampIso(chunk.endedAt, latestIso);
296
+ if (startedAt && endedAt && endedAt < startedAt) {
297
+ endedAt = startedAt;
298
+ }
299
+ return { ...chunk, startedAt, endedAt };
300
+ }
301
+
302
+ function clampReplayIngestTiming(
303
+ input: ParsedSessionReplayIngest,
304
+ latestIso: string,
305
+ ): ParsedSessionReplayIngest {
306
+ const chunks = input.chunks.map((chunk) =>
307
+ clampReplayChunkTiming(chunk, latestIso),
308
+ );
309
+ const startedAt =
310
+ replayMinIso([
311
+ replayClampIso(input.startedAt, latestIso),
312
+ ...chunks.map((chunk) => chunk.startedAt),
313
+ ]) ?? latestIso;
314
+ let endedAt = replayMaxIso([
315
+ replayClampIso(input.endedAt, latestIso),
316
+ ...chunks.map((chunk) => chunk.endedAt),
317
+ ]);
318
+ if (endedAt && endedAt < startedAt) endedAt = startedAt;
319
+ const durationMs = endedAt
320
+ ? Math.max(0, Date.parse(endedAt) - Date.parse(startedAt))
321
+ : input.durationMs;
322
+ return { ...input, startedAt, endedAt, durationMs, chunks };
323
+ }
324
+
278
325
  function normalizeReplayUrl(url: string | null): {
279
326
  url: string | null;
280
327
  path: string | null;
@@ -1101,7 +1148,8 @@ export async function recordSessionReplayChunks(
1101
1148
  }> {
1102
1149
  const key = await resolveReplayPublicKey(input.publicKey, context);
1103
1150
  const db = getDb() as any;
1104
- const ingestedAt = replayNowIso();
1151
+ const ingestedAt = replayTimestamp(context.now) ?? replayNowIso();
1152
+ const clampedInput = clampReplayIngestTiming(input, ingestedAt);
1105
1153
 
1106
1154
  let [recording] = await db
1107
1155
  .select()
@@ -1109,7 +1157,10 @@ export async function recordSessionReplayChunks(
1109
1157
  .where(
1110
1158
  and(
1111
1159
  eq(schema.sessionRecordings.publicKeyId, key.id),
1112
- eq(schema.sessionRecordings.clientRecordingId, input.clientRecordingId),
1160
+ eq(
1161
+ schema.sessionRecordings.clientRecordingId,
1162
+ clampedInput.clientRecordingId,
1163
+ ),
1113
1164
  ),
1114
1165
  )
1115
1166
  .limit(1);
@@ -1121,27 +1172,27 @@ export async function recordSessionReplayChunks(
1121
1172
  .values({
1122
1173
  id: newRecordingId,
1123
1174
  publicKeyId: key.id,
1124
- clientRecordingId: input.clientRecordingId,
1125
- sessionId: input.sessionId,
1126
- userId: input.userId,
1127
- anonymousId: input.anonymousId,
1128
- userKey: input.userKey,
1129
- startedAt: input.startedAt,
1130
- endedAt: input.endedAt,
1131
- durationMs: input.durationMs,
1132
- pageCount: input.pageCount,
1133
- errorCount: input.errorCount,
1134
- rageClickCount: input.rageClickCount,
1135
- privacyMode: input.privacyMode,
1136
- firstUrl: input.url,
1137
- lastUrl: input.url,
1138
- path: input.path,
1139
- hostname: input.hostname,
1140
- referrer: input.referrer,
1141
- app: input.app,
1142
- template: input.template,
1143
- status: input.status,
1144
- metadata: JSON.stringify(input.metadata),
1175
+ clientRecordingId: clampedInput.clientRecordingId,
1176
+ sessionId: clampedInput.sessionId,
1177
+ userId: clampedInput.userId,
1178
+ anonymousId: clampedInput.anonymousId,
1179
+ userKey: clampedInput.userKey,
1180
+ startedAt: clampedInput.startedAt,
1181
+ endedAt: clampedInput.endedAt,
1182
+ durationMs: clampedInput.durationMs,
1183
+ pageCount: clampedInput.pageCount,
1184
+ errorCount: clampedInput.errorCount,
1185
+ rageClickCount: clampedInput.rageClickCount,
1186
+ privacyMode: clampedInput.privacyMode,
1187
+ firstUrl: clampedInput.url,
1188
+ lastUrl: clampedInput.url,
1189
+ path: clampedInput.path,
1190
+ hostname: clampedInput.hostname,
1191
+ referrer: clampedInput.referrer,
1192
+ app: clampedInput.app,
1193
+ template: clampedInput.template,
1194
+ status: clampedInput.status,
1195
+ metadata: JSON.stringify(clampedInput.metadata),
1145
1196
  lastIngestedAt: ingestedAt,
1146
1197
  ownerEmail: key.ownerEmail,
1147
1198
  orgId: key.orgId,
@@ -1162,7 +1213,7 @@ export async function recordSessionReplayChunks(
1162
1213
  eq(schema.sessionRecordings.publicKeyId, key.id),
1163
1214
  eq(
1164
1215
  schema.sessionRecordings.clientRecordingId,
1165
- input.clientRecordingId,
1216
+ clampedInput.clientRecordingId,
1166
1217
  ),
1167
1218
  ),
1168
1219
  )
@@ -1193,7 +1244,7 @@ export async function recordSessionReplayChunks(
1193
1244
  existingChunks.length === 0;
1194
1245
 
1195
1246
  try {
1196
- for (const rawChunk of input.chunks) {
1247
+ for (const rawChunk of clampedInput.chunks) {
1197
1248
  const existing = existingBySeq.get(rawChunk.seq);
1198
1249
  if (existing) {
1199
1250
  if (existing.checksum !== rawChunk.checksum) {
@@ -1272,13 +1323,15 @@ export async function recordSessionReplayChunks(
1272
1323
  id: replayId("sri"),
1273
1324
  publicKeyId: key.id,
1274
1325
  recordingId: recording.id,
1275
- byteLength: replayIngestByteLength(input, context),
1326
+ byteLength: replayIngestByteLength(clampedInput, context),
1276
1327
  createdAt: ingestedAt,
1277
1328
  ownerEmail: key.ownerEmail,
1278
1329
  orgId: key.orgId,
1279
1330
  });
1280
1331
 
1281
- const allChunks = [...existingChunks, ...rowsToInsert];
1332
+ const allChunks = [...existingChunks, ...rowsToInsert].map((chunk: any) =>
1333
+ clampReplayChunkTiming(chunk, ingestedAt),
1334
+ );
1282
1335
  const chunkCount = allChunks.length;
1283
1336
  const eventCount = allChunks.reduce(
1284
1337
  (sum, chunk: any) => sum + Number(chunk.eventCount ?? 0),
@@ -1291,57 +1344,63 @@ export async function recordSessionReplayChunks(
1291
1344
  const startedAt =
1292
1345
  replayMinIso([
1293
1346
  recording.startedAt,
1294
- input.startedAt,
1347
+ clampedInput.startedAt,
1295
1348
  ...allChunks.map((chunk: any) => chunk.startedAt),
1296
- ]) ?? input.startedAt;
1349
+ ]) ?? clampedInput.startedAt;
1297
1350
  const endedAt =
1298
1351
  replayMaxIso([
1299
1352
  recording.endedAt,
1300
- input.endedAt,
1353
+ clampedInput.endedAt,
1301
1354
  ...allChunks.map((chunk: any) => chunk.endedAt),
1302
1355
  ]) ?? null;
1303
1356
  const durationMs =
1304
- input.durationMs ??
1357
+ clampedInput.durationMs ??
1305
1358
  (endedAt
1306
1359
  ? Math.max(0, Date.parse(endedAt) - Date.parse(startedAt))
1307
1360
  : (recording.durationMs ?? null));
1308
1361
  const metadata = mergeReplayMetadata(
1309
1362
  parseRecordingMetadata(recording),
1310
- input.metadata,
1363
+ clampedInput.metadata,
1311
1364
  );
1312
1365
 
1313
1366
  await db
1314
1367
  .update(schema.sessionRecordings)
1315
1368
  .set({
1316
- sessionId: input.sessionId,
1317
- userId: input.userId ?? recording.userId ?? null,
1318
- anonymousId: input.anonymousId ?? recording.anonymousId ?? null,
1319
- userKey: input.userKey ?? recording.userKey ?? null,
1369
+ sessionId: clampedInput.sessionId,
1370
+ userId: clampedInput.userId ?? recording.userId ?? null,
1371
+ anonymousId: clampedInput.anonymousId ?? recording.anonymousId ?? null,
1372
+ userKey: clampedInput.userKey ?? recording.userKey ?? null,
1320
1373
  startedAt,
1321
1374
  endedAt,
1322
1375
  durationMs,
1323
1376
  chunkCount,
1324
1377
  eventCount,
1325
1378
  totalBytes,
1326
- pageCount: Math.max(Number(recording.pageCount ?? 0), input.pageCount),
1327
- errorCount: Math.max(Number(recording.errorCount ?? 0), input.errorCount),
1379
+ pageCount: Math.max(
1380
+ Number(recording.pageCount ?? 0),
1381
+ clampedInput.pageCount,
1382
+ ),
1383
+ errorCount: Math.max(
1384
+ Number(recording.errorCount ?? 0),
1385
+ clampedInput.errorCount,
1386
+ ),
1328
1387
  rageClickCount: Math.max(
1329
1388
  Number(recording.rageClickCount ?? 0),
1330
- input.rageClickCount,
1389
+ clampedInput.rageClickCount,
1331
1390
  ),
1332
1391
  privacyMode:
1333
- input.privacyMode !== "unknown"
1334
- ? input.privacyMode
1392
+ clampedInput.privacyMode !== "unknown"
1393
+ ? clampedInput.privacyMode
1335
1394
  : (recording.privacyMode ?? "unknown"),
1336
- firstUrl: recording.firstUrl ?? input.url,
1337
- lastUrl: input.url ?? recording.lastUrl ?? null,
1338
- path: input.path ?? recording.path ?? null,
1339
- hostname: input.hostname ?? recording.hostname ?? null,
1340
- referrer: input.referrer ?? recording.referrer ?? null,
1341
- app: input.app ?? recording.app ?? null,
1342
- template: input.template ?? recording.template ?? null,
1395
+ firstUrl: recording.firstUrl ?? clampedInput.url,
1396
+ lastUrl: clampedInput.url ?? recording.lastUrl ?? null,
1397
+ path: clampedInput.path ?? recording.path ?? null,
1398
+ hostname: clampedInput.hostname ?? recording.hostname ?? null,
1399
+ referrer: clampedInput.referrer ?? recording.referrer ?? null,
1400
+ app: clampedInput.app ?? recording.app ?? null,
1401
+ template: clampedInput.template ?? recording.template ?? null,
1343
1402
  status:
1344
- input.status === "completed" || recording.status === "completed"
1403
+ clampedInput.status === "completed" || recording.status === "completed"
1345
1404
  ? "completed"
1346
1405
  : "active",
1347
1406
  metadata: JSON.stringify(metadata),
@@ -1364,7 +1423,7 @@ export async function recordSessionReplayChunks(
1364
1423
 
1365
1424
  return {
1366
1425
  recordingId: recording.id,
1367
- sessionId: input.sessionId,
1426
+ sessionId: clampedInput.sessionId,
1368
1427
  acceptedChunks: rowsToInsert.length,
1369
1428
  duplicateChunks,
1370
1429
  chunkCount,
@@ -492,6 +492,27 @@ export default runMigrations(
492
492
  version: 71,
493
493
  sql: `CREATE INDEX IF NOT EXISTS session_replay_ingests_recording_idx ON session_replay_ingests (recording_id)`,
494
494
  },
495
+ {
496
+ version: 72,
497
+ sql: {
498
+ postgres: `UPDATE analytics_events SET timestamp = COALESCE(NULLIF(received_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), event_date = substr(COALESCE(NULLIF(received_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), 1, 10) WHERE COALESCE(NULLIF(event_date, ''), substr(timestamp, 1, 10)) > to_char(CURRENT_DATE, 'YYYY-MM-DD')`,
499
+ sqlite: `UPDATE analytics_events SET timestamp = COALESCE(NULLIF(received_at, ''), datetime('now')), event_date = substr(COALESCE(NULLIF(received_at, ''), date('now')), 1, 10) WHERE COALESCE(NULLIF(event_date, ''), substr(timestamp, 1, 10)) > date('now')`,
500
+ },
501
+ },
502
+ {
503
+ version: 73,
504
+ sql: {
505
+ postgres: `UPDATE session_recordings SET started_at = CASE WHEN substr(started_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') THEN LEAST(COALESCE(NULLIF(last_ingested_at, ''), NULLIF(updated_at, ''), NULLIF(created_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ELSE started_at END, ended_at = CASE WHEN ended_at IS NOT NULL AND substr(ended_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') THEN LEAST(COALESCE(NULLIF(last_ingested_at, ''), NULLIF(updated_at, ''), NULLIF(created_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ELSE ended_at END WHERE (owner_email IS NOT NULL OR org_id IS NOT NULL) AND (substr(started_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') OR (ended_at IS NOT NULL AND substr(ended_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD')))`,
506
+ sqlite: `UPDATE session_recordings SET started_at = CASE WHEN substr(started_at, 1, 10) > date('now') THEN min(COALESCE(NULLIF(last_ingested_at, ''), NULLIF(updated_at, ''), NULLIF(created_at, ''), datetime('now')), datetime('now')) ELSE started_at END, ended_at = CASE WHEN ended_at IS NOT NULL AND substr(ended_at, 1, 10) > date('now') THEN min(COALESCE(NULLIF(last_ingested_at, ''), NULLIF(updated_at, ''), NULLIF(created_at, ''), datetime('now')), datetime('now')) ELSE ended_at END WHERE (owner_email IS NOT NULL OR org_id IS NOT NULL) AND (substr(started_at, 1, 10) > date('now') OR (ended_at IS NOT NULL AND substr(ended_at, 1, 10) > date('now')))`,
507
+ },
508
+ },
509
+ {
510
+ version: 74,
511
+ sql: {
512
+ postgres: `UPDATE session_replay_chunks SET started_at = CASE WHEN started_at IS NOT NULL AND substr(started_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') THEN LEAST(COALESCE(NULLIF(created_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ELSE started_at END, ended_at = CASE WHEN ended_at IS NOT NULL AND substr(ended_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') THEN LEAST(COALESCE(NULLIF(created_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ELSE ended_at END WHERE (started_at IS NOT NULL AND substr(started_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD')) OR (ended_at IS NOT NULL AND substr(ended_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD'))`,
513
+ sqlite: `UPDATE session_replay_chunks SET started_at = CASE WHEN started_at IS NOT NULL AND substr(started_at, 1, 10) > date('now') THEN min(COALESCE(NULLIF(created_at, ''), datetime('now')), datetime('now')) ELSE started_at END, ended_at = CASE WHEN ended_at IS NOT NULL AND substr(ended_at, 1, 10) > date('now') THEN min(COALESCE(NULLIF(created_at, ''), datetime('now')), datetime('now')) ELSE ended_at END WHERE (started_at IS NOT NULL AND substr(started_at, 1, 10) > date('now')) OR (ended_at IS NOT NULL AND substr(ended_at, 1, 10) > date('now'))`,
514
+ },
515
+ },
495
516
  ],
496
517
  { table: "analytics_migrations" },
497
518
  );
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "name": "Agent-Native Clips",
4
4
  "short_name": "Clips",
5
- "version": "0.1.7",
5
+ "version": "0.1.8",
6
6
  "description": "Start Clips recordings from Chrome, capture optional diagnostics, and preview Clips links on GitHub.",
7
7
  "minimum_chrome_version": "116",
8
8
  "action": {
@@ -815,6 +815,134 @@ function sanitizeHtml2CanvasClone(
815
815
  });
816
816
  }
817
817
 
818
+ /**
819
+ * Editor-chrome overlays that editor-chrome.bridge.ts appends inside the preview
820
+ * iframe (the selection outline + resize handles, hover highlight, marquee,
821
+ * spacing/measurement guides, and badges). They live in the iframe DOM, so image
822
+ * exports must strip them from the clone — otherwise a download captures the
823
+ * editor's selection outline instead of just the design. Keep this in sync with
824
+ * the data-agent-native-* markers set in editor-chrome.bridge.ts.
825
+ */
826
+ export const EDITOR_CHROME_OVERLAY_SELECTOR = [
827
+ "[data-agent-native-edit-overlay]",
828
+ "[data-agent-native-edit-handle]",
829
+ "[data-agent-native-edge-handle]",
830
+ "[data-agent-native-rotate-handle]",
831
+ "[data-agent-native-transform-badge]",
832
+ "[data-agent-native-spacing-badge]",
833
+ "[data-agent-native-spacing-overlay]",
834
+ "[data-agent-native-spacing-line]",
835
+ "[data-agent-native-spacing-region]",
836
+ "[data-agent-native-insertion-guide]",
837
+ "[data-agent-native-measurement-overlay]",
838
+ ].join(",");
839
+
840
+ /**
841
+ * Remove editor-chrome overlays from a cloned document/element before it is
842
+ * rasterized (PNG) or serialized (SVG) for export.
843
+ */
844
+ function removeEditorChromeOverlays(root: ParentNode): void {
845
+ root
846
+ .querySelectorAll(EDITOR_CHROME_OVERLAY_SELECTOR)
847
+ .forEach((element) => element.remove());
848
+ }
849
+
850
+ /**
851
+ * Resolve the document-space rect of the currently selected element inside the
852
+ * preview iframe so image exports (PNG/SVG) can crop to just that frame instead
853
+ * of the whole screen. Returns null — meaning "export the whole screen" — when
854
+ * there is no element selection, when the selection is the screen root
855
+ * (BODY/HTML, which is the whole screen anyway), or when the element can no
856
+ * longer be resolved in the live document.
857
+ */
858
+ function resolveExportCropRect(
859
+ doc: Document,
860
+ selected: ElementInfo | null | undefined,
861
+ ): { x: number; y: number; width: number; height: number } | null {
862
+ if (!selected || isScreenRootElementInfo(selected)) return null;
863
+ const view = doc.defaultView;
864
+ if (!view) return null;
865
+ let element: Element | null = null;
866
+ if (selected.sourceId) {
867
+ try {
868
+ element = doc.querySelector(
869
+ `[data-agent-native-node-id="${CSS.escape(selected.sourceId)}"]`,
870
+ );
871
+ } catch {
872
+ element = null;
873
+ }
874
+ }
875
+ if (!element && selected.selector) {
876
+ try {
877
+ element = doc.querySelector(selected.selector);
878
+ } catch {
879
+ element = null;
880
+ }
881
+ }
882
+ if (!element) return null;
883
+ const rect = element.getBoundingClientRect();
884
+ if (rect.width <= 0 || rect.height <= 0) return null;
885
+ // getBoundingClientRect is viewport-relative; add the iframe scroll offset so
886
+ // coordinates match the full-document render (which starts at the page top).
887
+ return {
888
+ x: rect.left + (view.scrollX ?? 0),
889
+ y: rect.top + (view.scrollY ?? 0),
890
+ width: rect.width,
891
+ height: rect.height,
892
+ };
893
+ }
894
+
895
+ /**
896
+ * Map a document-space rect onto pixel coordinates within a rendered canvas of
897
+ * the given size, clamped to stay inside the canvas. `scale` must match the
898
+ * scale passed to html2canvas. Returns null when the crop would be empty or
899
+ * lands fully outside the canvas, so callers can fall back to the full render.
900
+ */
901
+ export function computeExportCropBox(
902
+ sourceWidth: number,
903
+ sourceHeight: number,
904
+ rect: { x: number; y: number; width: number; height: number },
905
+ scale: number,
906
+ ): { sx: number; sy: number; sw: number; sh: number } | null {
907
+ const sx = Math.max(0, Math.round(rect.x * scale));
908
+ const sy = Math.max(0, Math.round(rect.y * scale));
909
+ const sw = Math.min(sourceWidth - sx, Math.round(rect.width * scale));
910
+ const sh = Math.min(sourceHeight - sy, Math.round(rect.height * scale));
911
+ if (sw <= 0 || sh <= 0) return null;
912
+ return { sx, sy, sw, sh };
913
+ }
914
+
915
+ /**
916
+ * Crop a rendered html2canvas canvas down to a document-space rect so image
917
+ * exports capture just the selected frame. Returns null when the crop is empty,
918
+ * so callers can fall back to the full render.
919
+ */
920
+ function cropCanvasToRect(
921
+ source: HTMLCanvasElement,
922
+ rect: { x: number; y: number; width: number; height: number },
923
+ scale: number,
924
+ ): HTMLCanvasElement | null {
925
+ const box = computeExportCropBox(source.width, source.height, rect, scale);
926
+ if (!box) return null;
927
+ const cropped = document.createElement("canvas");
928
+ cropped.width = box.sw;
929
+ cropped.height = box.sh;
930
+ const context = cropped.getContext("2d");
931
+ if (!context) return null;
932
+ context.drawImage(
933
+ source,
934
+ box.sx,
935
+ box.sy,
936
+ box.sw,
937
+ box.sh,
938
+ 0,
939
+ 0,
940
+ box.sw,
941
+ box.sh,
942
+ );
943
+ return cropped;
944
+ }
945
+
818
946
  function byteLength(value: string): number {
819
947
  if (typeof TextEncoder === "undefined") return value.length;
820
948
  return new TextEncoder().encode(value).length;
@@ -9068,6 +9196,11 @@ export default function DesignEditor() {
9068
9196
  title: shortLabel,
9069
9197
  context: contextLines.join("\n"),
9070
9198
  openSidebar: false,
9199
+ // Mirror the selection into chat context without stealing focus: this
9200
+ // effect re-fires on every selection change and on each get-design poll
9201
+ // during an agent run, and focusing the composer here would blur (and
9202
+ // tear down) an in-progress inline text edit on the canvas.
9203
+ focus: false,
9071
9204
  });
9072
9205
  }, [activeFile, design?.title, id, selectedCodeLayerNode, selectedElement]);
9073
9206
 
@@ -13174,26 +13307,38 @@ export default function DesignEditor() {
13174
13307
  doc.body?.scrollHeight ?? 0,
13175
13308
  iframe?.clientHeight ?? 0,
13176
13309
  );
13310
+ const exportScale = Math.max(
13311
+ 0.1,
13312
+ Math.min(
13313
+ 4,
13314
+ settings?.scale ?? Math.min(2, window.devicePixelRatio || 1),
13315
+ ),
13316
+ );
13317
+ // When an element is selected, crop the export to just that frame.
13318
+ const cropRect = resolveExportCropRect(doc, selectedElement);
13177
13319
  const canvas = await html2canvas(doc.documentElement, {
13178
13320
  width,
13179
13321
  height,
13180
13322
  windowWidth: width,
13181
13323
  windowHeight: height,
13182
- scale: Math.max(
13183
- 0.1,
13184
- Math.min(
13185
- 4,
13186
- settings?.scale ?? Math.min(2, window.devicePixelRatio || 1),
13187
- ),
13188
- ),
13324
+ scale: exportScale,
13189
13325
  useCORS: true,
13190
13326
  foreignObjectRendering: true,
13191
13327
  backgroundColor: null,
13192
- onclone: (clonedDocument) =>
13193
- sanitizeHtml2CanvasClone(doc, clonedDocument),
13328
+ onclone: (clonedDocument) => {
13329
+ // Sanitize colors first: it aligns source/clone elements by index,
13330
+ // so remove the editor-chrome overlays only afterwards.
13331
+ sanitizeHtml2CanvasClone(doc, clonedDocument);
13332
+ removeEditorChromeOverlays(clonedDocument);
13333
+ },
13194
13334
  });
13335
+ // Render the whole page first, then crop, so ancestor backgrounds show
13336
+ // through the selected frame exactly as they do on screen.
13337
+ const outputCanvas = cropRect
13338
+ ? (cropCanvasToRect(canvas, cropRect, exportScale) ?? canvas)
13339
+ : canvas;
13195
13340
  await new Promise<void>((resolve) => {
13196
- canvas.toBlob((blob) => {
13341
+ outputCanvas.toBlob((blob) => {
13197
13342
  try {
13198
13343
  if (!blob) {
13199
13344
  toast.error(t("designEditor.toasts.pngCreateError"));
@@ -13235,7 +13380,7 @@ export default function DesignEditor() {
13235
13380
  setPngExporting(false);
13236
13381
  }
13237
13382
  },
13238
- [fallbackExportName, t, triggerBlobDownload],
13383
+ [fallbackExportName, selectedElement, t, triggerBlobDownload],
13239
13384
  );
13240
13385
 
13241
13386
  const handleDownloadSvg = useCallback(
@@ -13297,6 +13442,9 @@ export default function DesignEditor() {
13297
13442
  clonedStylesheetLinks[index]?.replaceWith(style);
13298
13443
  });
13299
13444
  clone.querySelectorAll("script").forEach((node) => node.remove());
13445
+ // Strip the editor's selection outline / handles so the SVG shows only
13446
+ // the design, not the editor chrome.
13447
+ removeEditorChromeOverlays(clone);
13300
13448
  clone.style.width = `${width}px`;
13301
13449
  clone.style.minHeight = `${height}px`;
13302
13450
 
@@ -13315,8 +13463,17 @@ export default function DesignEditor() {
13315
13463
  .replace(/</g, "&lt;")
13316
13464
  .replace(/>/g, "&gt;") || t("designEditor.designExport");
13317
13465
  const exportScale = Math.max(0.1, Math.min(4, settings?.scale ?? 1));
13466
+ // When an element is selected, crop to just that frame by narrowing the
13467
+ // SVG viewBox to its document-space rect. The foreignObject still holds
13468
+ // the full document so layout and inherited styles stay intact; the
13469
+ // viewBox clips the visible region to the selection.
13470
+ const cropRect = resolveExportCropRect(doc, selectedElement);
13471
+ const viewX = cropRect?.x ?? 0;
13472
+ const viewY = cropRect?.y ?? 0;
13473
+ const viewWidth = cropRect?.width ?? width;
13474
+ const viewHeight = cropRect?.height ?? height;
13318
13475
  const svg = `<?xml version="1.0" encoding="UTF-8"?>
13319
- <svg xmlns="http://www.w3.org/2000/svg" width="${width * exportScale}" height="${height * exportScale}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${safeTitle}">
13476
+ <svg xmlns="http://www.w3.org/2000/svg" width="${viewWidth * exportScale}" height="${viewHeight * exportScale}" viewBox="${viewX} ${viewY} ${viewWidth} ${viewHeight}" role="img" aria-label="${safeTitle}">
13320
13477
  <title>${safeTitle}</title>
13321
13478
  <foreignObject width="${width}" height="${height}">
13322
13479
  ${serializedHtml}
@@ -13339,7 +13496,13 @@ ${serializedHtml}
13339
13496
  setSvgExporting(false);
13340
13497
  }
13341
13498
  },
13342
- [design?.title, fallbackExportName, t, triggerBlobDownload],
13499
+ [
13500
+ design?.title,
13501
+ fallbackExportName,
13502
+ selectedElement,
13503
+ t,
13504
+ triggerBlobDownload,
13505
+ ],
13343
13506
  );
13344
13507
 
13345
13508
  const handleInspectorExport = useCallback(
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Exporting a design as PNG or SVG now downloads just the selected frame instead of the entire screen.
@@ -57,6 +57,7 @@ export declare const AGENT_BACKGROUND_FUNCTION_URL_PATH = "/.netlify/functions/s
57
57
  * to shadow because `/.netlify/*` is already excluded from the `server` catch-all.
58
58
  */
59
59
  export declare function resolveAgentChatProcessRunDispatchPath(): string;
60
+ export declare function dispatchPathTargetsNetlifyBackgroundFunction(dispatchPath: string): boolean;
60
61
  /**
61
62
  * Env flag for durable background runs. DEFAULT-OFF (opt-in): unset means
62
63
  * disabled; an app opts IN with an explicit truthy value (`true`/`1`/`yes`/`on`).
@@ -1 +1 @@
1
- {"version":3,"file":"durable-background.d.ts","sourceRoot":"","sources":["../../src/agent/durable-background.ts"],"names":[],"mappings":"AAgDA;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,2CACE,CAAC;AAE3C;;;;;;;;GAQG;AACH,eAAO,MAAM,8BAA8B,4BAA4B,CAAC;AAExE;;;;;;;;;GASG;AACH,eAAO,MAAM,kCAAkC,gDAA0D,CAAC;AAiC1G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,sCAAsC,IAAI,MAAM,CAQ/D;AAED;;;GAGG;AACH,eAAO,MAAM,iCAAiC,kCACb,CAAC;AAElC;;;;;;GAMG;AACH,eAAO,MAAM,+BAA+B,oBAAoB,CAAC;AAEjE;;;;GAIG;AACH,wBAAgB,mCAAmC,IAAI,OAAO,CAsB7D;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,6BAA6B,IAAI,OAAO,CAsBvD;AAED,wBAAgB,2CAA2C,CACzD,MAAM,EAAE,OAAO,GACd,OAAO,CAOT;AAED,wBAAgB,2CAA2C,CACzD,MAAM,EAAE,OAAO,GACd,OAAO,CAKT;AAED,wBAAgB,iCAAiC,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAQzE;AAwCD;;;;;;;GAOG;AACH,wBAAgB,mCAAmC,CAAC,OAAO,CAAC,EAAE;IAC5D,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAWV;AAED,uDAAuD;AACvD,MAAM,MAAM,qBAAqB,GAC7B;IACE,EAAE,EAAE,IAAI,CAAC;IACT,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B,GACD;IACE,EAAE,EAAE,KAAK,CAAC;IACV,2CAA2C;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB,CAAC;AAEN;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAahE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,OAAO,EACb,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,qBAAqB,CAiDvB"}
1
+ {"version":3,"file":"durable-background.d.ts","sourceRoot":"","sources":["../../src/agent/durable-background.ts"],"names":[],"mappings":"AAgDA;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,2CACE,CAAC;AAE3C;;;;;;;;GAQG;AACH,eAAO,MAAM,8BAA8B,4BAA4B,CAAC;AAExE;;;;;;;;;GASG;AACH,eAAO,MAAM,kCAAkC,gDAA0D,CAAC;AAiC1G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,sCAAsC,IAAI,MAAM,CAQ/D;AAED,wBAAgB,4CAA4C,CAC1D,YAAY,EAAE,MAAM,GACnB,OAAO,CAET;AAED;;;GAGG;AACH,eAAO,MAAM,iCAAiC,kCACb,CAAC;AAElC;;;;;;GAMG;AACH,eAAO,MAAM,+BAA+B,oBAAoB,CAAC;AAEjE;;;;GAIG;AACH,wBAAgB,mCAAmC,IAAI,OAAO,CAsB7D;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,6BAA6B,IAAI,OAAO,CAsBvD;AAED,wBAAgB,2CAA2C,CACzD,MAAM,EAAE,OAAO,GACd,OAAO,CAOT;AAED,wBAAgB,2CAA2C,CACzD,MAAM,EAAE,OAAO,GACd,OAAO,CAKT;AAED,wBAAgB,iCAAiC,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAQzE;AAwCD;;;;;;;GAOG;AACH,wBAAgB,mCAAmC,CAAC,OAAO,CAAC,EAAE;IAC5D,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAWV;AAED,uDAAuD;AACvD,MAAM,MAAM,qBAAqB,GAC7B;IACE,EAAE,EAAE,IAAI,CAAC;IACT,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B,GACD;IACE,EAAE,EAAE,KAAK,CAAC;IACV,2CAA2C;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB,CAAC;AAEN;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAahE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,OAAO,EACb,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,qBAAqB,CAiDvB"}
@@ -138,6 +138,9 @@ export function resolveAgentChatProcessRunDispatchPath() {
138
138
  }
139
139
  return AGENT_CHAT_PROCESS_RUN_PATH;
140
140
  }
141
+ export function dispatchPathTargetsNetlifyBackgroundFunction(dispatchPath) {
142
+ return dispatchPath.startsWith("/.netlify/functions/");
143
+ }
141
144
  /**
142
145
  * Env flag for durable background runs. DEFAULT-OFF (opt-in): unset means
143
146
  * disabled; an app opts IN with an explicit truthy value (`true`/`1`/`yes`/`on`).