@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +23 -0
- package/corpus/core/package.json +2 -2
- package/corpus/core/src/agent/durable-background.ts +6 -0
- package/corpus/core/src/agent/engine/builder-engine.ts +7 -3
- package/corpus/core/src/agent/production-agent.ts +10 -3
- package/corpus/core/src/client/AssistantChat.tsx +30 -4
- package/corpus/core/src/client/MultiTabAssistantChat.tsx +11 -3
- package/corpus/core/src/client/agent-chat.ts +18 -3
- package/corpus/core/src/client/use-agent-engine-configured.ts +7 -4
- package/corpus/core/src/db/client.ts +10 -1
- package/corpus/core/src/server/agent-chat-plugin.ts +69 -43
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/DashboardFilterBar.tsx +18 -24
- package/corpus/templates/analytics/changelog/2026-07-01-dashboard-charts-no-longer-show-future-dates-when-clients-se.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-01-dashboard-filters-take-less-space-and-keep-view-actions-tucked.md +6 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +34 -9
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +6 -2
- package/corpus/templates/analytics/server/lib/session-replay.ts +109 -50
- package/corpus/templates/analytics/server/plugins/db.ts +21 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/design/app/pages/DesignEditor.tsx +176 -13
- package/corpus/templates/design/changelog/2026-07-01-exporting-a-design-as-png-or-svg-now-downloads-just-the-sele.md +6 -0
- package/dist/agent/durable-background.d.ts +1 -0
- package/dist/agent/durable-background.d.ts.map +1 -1
- package/dist/agent/durable-background.js +3 -0
- package/dist/agent/durable-background.js.map +1 -1
- package/dist/agent/engine/builder-engine.d.ts.map +1 -1
- package/dist/agent/engine/builder-engine.js +7 -1
- package/dist/agent/engine/builder-engine.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +6 -4
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +8 -2
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +18 -2
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +7 -3
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/agent-chat.d.ts +7 -0
- package/dist/client/agent-chat.d.ts.map +1 -1
- package/dist/client/agent-chat.js +5 -1
- package/dist/client/agent-chat.js.map +1 -1
- package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
- package/dist/client/use-agent-engine-configured.js +7 -1
- package/dist/client/use-agent-engine-configured.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/db/client.d.ts +6 -0
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +8 -1
- package/dist/db/client.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +2 -2
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +57 -40
- package/dist/server/agent-chat-plugin.js.map +1 -1
- 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(
|
|
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:
|
|
1125
|
-
sessionId:
|
|
1126
|
-
userId:
|
|
1127
|
-
anonymousId:
|
|
1128
|
-
userKey:
|
|
1129
|
-
startedAt:
|
|
1130
|
-
endedAt:
|
|
1131
|
-
durationMs:
|
|
1132
|
-
pageCount:
|
|
1133
|
-
errorCount:
|
|
1134
|
-
rageClickCount:
|
|
1135
|
-
privacyMode:
|
|
1136
|
-
firstUrl:
|
|
1137
|
-
lastUrl:
|
|
1138
|
-
path:
|
|
1139
|
-
hostname:
|
|
1140
|
-
referrer:
|
|
1141
|
-
app:
|
|
1142
|
-
template:
|
|
1143
|
-
status:
|
|
1144
|
-
metadata: JSON.stringify(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
1347
|
+
clampedInput.startedAt,
|
|
1295
1348
|
...allChunks.map((chunk: any) => chunk.startedAt),
|
|
1296
|
-
]) ??
|
|
1349
|
+
]) ?? clampedInput.startedAt;
|
|
1297
1350
|
const endedAt =
|
|
1298
1351
|
replayMaxIso([
|
|
1299
1352
|
recording.endedAt,
|
|
1300
|
-
|
|
1353
|
+
clampedInput.endedAt,
|
|
1301
1354
|
...allChunks.map((chunk: any) => chunk.endedAt),
|
|
1302
1355
|
]) ?? null;
|
|
1303
1356
|
const durationMs =
|
|
1304
|
-
|
|
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
|
-
|
|
1363
|
+
clampedInput.metadata,
|
|
1311
1364
|
);
|
|
1312
1365
|
|
|
1313
1366
|
await db
|
|
1314
1367
|
.update(schema.sessionRecordings)
|
|
1315
1368
|
.set({
|
|
1316
|
-
sessionId:
|
|
1317
|
-
userId:
|
|
1318
|
-
anonymousId:
|
|
1319
|
-
userKey:
|
|
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(
|
|
1327
|
-
|
|
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
|
-
|
|
1389
|
+
clampedInput.rageClickCount,
|
|
1331
1390
|
),
|
|
1332
1391
|
privacyMode:
|
|
1333
|
-
|
|
1334
|
-
?
|
|
1392
|
+
clampedInput.privacyMode !== "unknown"
|
|
1393
|
+
? clampedInput.privacyMode
|
|
1335
1394
|
: (recording.privacyMode ?? "unknown"),
|
|
1336
|
-
firstUrl: recording.firstUrl ??
|
|
1337
|
-
lastUrl:
|
|
1338
|
-
path:
|
|
1339
|
-
hostname:
|
|
1340
|
-
referrer:
|
|
1341
|
-
app:
|
|
1342
|
-
template:
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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, "<")
|
|
13316
13464
|
.replace(/>/g, ">") || 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="${
|
|
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
|
-
[
|
|
13499
|
+
[
|
|
13500
|
+
design?.title,
|
|
13501
|
+
fallbackExportName,
|
|
13502
|
+
selectedElement,
|
|
13503
|
+
t,
|
|
13504
|
+
triggerBlobDownload,
|
|
13505
|
+
],
|
|
13343
13506
|
);
|
|
13344
13507
|
|
|
13345
13508
|
const handleInspectorExport = useCallback(
|
|
@@ -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`).
|