@agent-native/core 0.82.0 → 0.83.0

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 (63) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/CommandMenu.tsx +34 -12
  5. package/corpus/templates/analytics/actions/hubspot-deals.ts +64 -4
  6. package/corpus/templates/content/.env.local.example +6 -0
  7. package/corpus/templates/content/AGENTS.md +10 -1
  8. package/corpus/templates/content/actions/_builder-cms-read-client.ts +14 -1
  9. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +43 -0
  10. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +79 -7
  11. package/corpus/templates/content/actions/_database-source-utils.ts +987 -22
  12. package/corpus/templates/content/actions/_database-utils.ts +41 -0
  13. package/corpus/templates/content/actions/_local-file-documents.ts +74 -1
  14. package/corpus/templates/content/actions/attach-content-database-source.ts +33 -8
  15. package/corpus/templates/content/actions/change-content-database-source-role.ts +14 -0
  16. package/corpus/templates/content/actions/disconnect-content-database-source.ts +3 -0
  17. package/corpus/templates/content/actions/execute-builder-source-execution.ts +71 -3
  18. package/corpus/templates/content/actions/list-content-databases.ts +41 -33
  19. package/corpus/templates/content/actions/process-builder-body-hydration.ts +47 -0
  20. package/corpus/templates/content/actions/update-document.ts +93 -0
  21. package/corpus/templates/content/app/blocks/SourceComponentBlock.tsx +277 -0
  22. package/corpus/templates/content/app/blocks/contentBlockRegistry.tsx +2 -0
  23. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +45 -4
  24. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +5 -1
  25. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +92 -0
  26. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +5 -3
  27. package/corpus/templates/content/app/components/editor/extensions/registryBlocks.ts +41 -0
  28. package/corpus/templates/content/app/components/editor/registrySlashItems.ts +4 -0
  29. package/corpus/templates/content/app/global.css +10 -3
  30. package/corpus/templates/content/app/hooks/use-content-database.ts +24 -0
  31. package/corpus/templates/content/app/i18n/zh-TW.ts +24 -0
  32. package/corpus/templates/content/app/i18n-data.ts +212 -0
  33. package/corpus/templates/content/app/lib/content-command-search.ts +64 -0
  34. package/corpus/templates/content/app/root.tsx +236 -8
  35. package/corpus/templates/content/changelog/2026-06-29-builder-cms-sources-now-sync-article-bodies-through-content.md +6 -0
  36. package/corpus/templates/content/changelog/2026-06-30-builder-article-media-now-render-as-images-and-embeds-when-r.md +6 -0
  37. package/corpus/templates/content/changelog/2026-06-30-builder-source-components-now-render-as-preserved-preview-bl.md +6 -0
  38. package/corpus/templates/content/changelog/2026-06-30-cmd-k-search-now-opens-from-the-editor-and-finds-real-content.md +6 -0
  39. package/corpus/templates/content/package.json +3 -1
  40. package/corpus/templates/content/scripts/check-native-deps.mjs +57 -0
  41. package/corpus/templates/content/scripts/dev-database.mjs +83 -0
  42. package/corpus/templates/content/scripts/seed-demo-user.mjs +107 -0
  43. package/corpus/templates/content/server/db/schema.ts +27 -0
  44. package/corpus/templates/content/server/plugins/db.ts +33 -0
  45. package/corpus/templates/content/shared/api.ts +45 -0
  46. package/corpus/templates/content/shared/builder-mdx.ts +905 -5
  47. package/corpus/templates/content/shared/nfm-registry.ts +2 -0
  48. package/corpus/templates/content/shared/source-component-block.ts +141 -0
  49. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +5 -1
  50. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +229 -131
  51. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +103 -6
  52. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +6 -3
  53. package/corpus/templates/design/app/pages/DesignEditor.tsx +382 -72
  54. package/corpus/templates/design/changelog/2026-06-30-canvas-editing-now-keeps-undo-redo-and-cross-screen-layer-mo.md +6 -0
  55. package/dist/client/CommandMenu.d.ts +6 -2
  56. package/dist/client/CommandMenu.d.ts.map +1 -1
  57. package/dist/client/CommandMenu.js +25 -14
  58. package/dist/client/CommandMenu.js.map +1 -1
  59. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  60. package/dist/notifications/routes.d.ts +2 -2
  61. package/dist/observability/routes.d.ts +8 -8
  62. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  63. package/package.json +1 -1
@@ -36,6 +36,7 @@ import {
36
36
 
37
37
  import { builderDocsBlockConfigs } from "./builder-docs-blocks";
38
38
  import { inlineDatabaseBlockConfig } from "./inline-database-block";
39
+ import { sourceComponentBlockConfig } from "./source-component-block";
39
40
 
40
41
  /**
41
42
  * Register the content block library (the dev-doc + OpenAPI + standard
@@ -62,6 +63,7 @@ export function registerContentBlocks(registry: BlockRegistry): void {
62
63
  for (const block of builderDocsBlockConfigs) {
63
64
  registry.register(block);
64
65
  }
66
+ registry.register(sourceComponentBlockConfig);
65
67
  registry.register(inlineDatabaseBlockConfig);
66
68
  }
67
69
 
@@ -0,0 +1,141 @@
1
+ import {
2
+ defineBlock,
3
+ type BlockMdxConfig,
4
+ type BlockSpec,
5
+ } from "@agent-native/core/blocks/server";
6
+ import { z } from "zod";
7
+
8
+ export interface SourceComponentData {
9
+ provider: string;
10
+ componentName: string;
11
+ rawRef: string;
12
+ rawHash: string;
13
+ sourceLabel?: string;
14
+ previewStatus?: "available" | "unavailable" | "warning";
15
+ previewKind?: "summary" | "table" | "embed" | "symbol" | "component";
16
+ previewUrl?: string;
17
+ previewItems?: string[];
18
+ preview?: SourceComponentPreview;
19
+ title?: string;
20
+ summary?: string;
21
+ }
22
+
23
+ export interface SourceComponentPreview {
24
+ status: "available" | "unavailable" | "warning";
25
+ kind: "summary" | "table" | "embed" | "symbol" | "component";
26
+ label: string;
27
+ summary?: string;
28
+ fields?: SourceComponentPreviewField[];
29
+ table?: SourceComponentPreviewTable;
30
+ url?: string;
31
+ }
32
+
33
+ export interface SourceComponentPreviewField {
34
+ label: string;
35
+ value: string;
36
+ }
37
+
38
+ export interface SourceComponentPreviewTable {
39
+ columns: SourceComponentPreviewTableColumn[];
40
+ rows: Record<string, string>[];
41
+ truncated?: boolean;
42
+ }
43
+
44
+ export interface SourceComponentPreviewTableColumn {
45
+ id: string;
46
+ label: string;
47
+ }
48
+
49
+ const sourceComponentPreviewFieldSchema = z.object({
50
+ label: z.string().trim().min(1).max(80),
51
+ value: z.string().trim().max(200),
52
+ });
53
+
54
+ const sourceComponentPreviewTableColumnSchema = z.object({
55
+ id: z.string().trim().min(1).max(80),
56
+ label: z.string().trim().min(1).max(120),
57
+ });
58
+
59
+ const sourceComponentPreviewTableSchema = z.object({
60
+ columns: z.array(sourceComponentPreviewTableColumnSchema).max(12),
61
+ rows: z.array(z.record(z.string(), z.string().max(500))).max(6),
62
+ truncated: z.boolean().optional(),
63
+ });
64
+
65
+ const sourceComponentPreviewSchema = z.object({
66
+ status: z.enum(["available", "unavailable", "warning"]),
67
+ kind: z.enum(["summary", "table", "embed", "symbol", "component"]),
68
+ label: z.string().trim().min(1).max(160),
69
+ summary: z.string().trim().max(500).optional(),
70
+ fields: z.array(sourceComponentPreviewFieldSchema).max(8).optional(),
71
+ table: sourceComponentPreviewTableSchema.optional(),
72
+ url: z.string().trim().max(2_000).optional(),
73
+ }) satisfies z.ZodType<SourceComponentPreview>;
74
+
75
+ export const sourceComponentSchema = z.object({
76
+ provider: z.string().trim().min(1).max(80),
77
+ componentName: z.string().trim().min(1).max(160),
78
+ rawRef: z.string().trim().min(1),
79
+ rawHash: z.string().trim().min(1),
80
+ sourceLabel: z.string().trim().max(200).optional(),
81
+ previewStatus: z.enum(["available", "unavailable", "warning"]).optional(),
82
+ previewKind: z
83
+ .enum(["summary", "table", "embed", "symbol", "component"])
84
+ .optional(),
85
+ previewUrl: z.string().trim().max(2_000).optional(),
86
+ previewItems: z.array(z.string().trim().max(160)).max(8).optional(),
87
+ preview: sourceComponentPreviewSchema.optional(),
88
+ title: z.string().trim().max(200).optional(),
89
+ summary: z.string().trim().max(500).optional(),
90
+ }) as unknown as z.ZodType<SourceComponentData>;
91
+
92
+ export const sourceComponentMdx: BlockMdxConfig<SourceComponentData> = {
93
+ tag: "SourceComponent",
94
+ toAttrs: (data) => ({
95
+ provider: data.provider,
96
+ componentName: data.componentName,
97
+ rawRef: data.rawRef,
98
+ rawHash: data.rawHash,
99
+ sourceLabel: data.sourceLabel,
100
+ previewStatus: data.previewStatus,
101
+ previewKind: data.previewKind,
102
+ previewUrl: data.previewUrl,
103
+ previewItems: data.previewItems,
104
+ preview: data.preview as Record<string, unknown> | undefined,
105
+ previewTitle: data.title,
106
+ summary: data.summary,
107
+ }),
108
+ fromAttrs: (attrs) => ({
109
+ provider: attrs.string("provider") ?? "",
110
+ componentName: attrs.string("componentName") ?? "",
111
+ rawRef: attrs.string("rawRef") ?? "",
112
+ rawHash: attrs.string("rawHash") ?? "",
113
+ sourceLabel: attrs.string("sourceLabel"),
114
+ previewStatus: attrs.string("previewStatus") as
115
+ | SourceComponentData["previewStatus"]
116
+ | undefined,
117
+ previewKind: attrs.string("previewKind") as
118
+ | SourceComponentData["previewKind"]
119
+ | undefined,
120
+ previewUrl: attrs.string("previewUrl"),
121
+ previewItems: attrs.array<string>("previewItems"),
122
+ preview: attrs.object<SourceComponentPreview>("preview"),
123
+ title: attrs.string("previewTitle") ?? attrs.string("title"),
124
+ summary: attrs.string("summary"),
125
+ }),
126
+ };
127
+
128
+ const ServerReadStub = () => null;
129
+
130
+ export const sourceComponentBlockConfig: BlockSpec<SourceComponentData> =
131
+ defineBlock<SourceComponentData>({
132
+ type: "source-component",
133
+ schema: sourceComponentSchema,
134
+ mdx: sourceComponentMdx,
135
+ Read: ServerReadStub,
136
+ placement: ["block"],
137
+ editSurface: "none",
138
+ label: "Source component",
139
+ description:
140
+ "A read-only source-native component marker that preserves provider-specific body blocks for round-trip sync.",
141
+ });
@@ -596,7 +596,11 @@ export function DesignCanvas({
596
596
  )
597
597
  .replace("__TEXT_EDITING_ENABLED__", editMode ? "true" : "false")
598
598
  .replace("__EDITOR_CHROME_SCALE_X__", String(editorChromeScaleX))
599
- .replace("__EDITOR_CHROME_SCALE_Y__", String(editorChromeScaleY));
599
+ .replace("__EDITOR_CHROME_SCALE_Y__", String(editorChromeScaleY))
600
+ .replace(
601
+ "__DESIGN_CANVAS_SCREEN_ID__",
602
+ JSON.stringify(screenId ?? contentKey ?? ""),
603
+ );
600
604
  const embeddedWheelBridge = EMBEDDED_WHEEL_BRIDGE_SCRIPT.replace(
601
605
  "__EMBEDDED_WHEEL_FORWARDING_ENABLED__",
602
606
  isEmbeddedFrame ? "true" : "false",
@@ -749,6 +749,7 @@ export function MultiScreenCanvas({
749
749
  selector: string;
750
750
  sourceId?: string;
751
751
  } | null>(null);
752
+ const crossScreenParentDragCleanupRef = useRef<(() => void) | null>(null);
752
753
  /** Board-space point from the last cross-screen-drag "move" message. */
753
754
  const crossScreenLastBoardPointRef = useRef<{ x: number; y: number } | null>(
754
755
  null,
@@ -1144,20 +1145,132 @@ export function MultiScreenCanvas({
1144
1145
  useEffect(() => {
1145
1146
  if (!onCrossScreenElementDrop) return;
1146
1147
 
1148
+ const stopParentCrossScreenDrag = () => {
1149
+ crossScreenParentDragCleanupRef.current?.();
1150
+ crossScreenParentDragCleanupRef.current = null;
1151
+ };
1152
+
1147
1153
  const clearCrossScreenDrag = () => {
1154
+ stopParentCrossScreenDrag();
1148
1155
  setCrossScreenGhost(null);
1149
1156
  setCrossScreenTarget(null);
1150
1157
  crossScreenTargetRef.current = null;
1151
1158
  crossScreenDragMsgRef.current = null;
1152
1159
  };
1153
1160
 
1161
+ const updateCrossScreenTargetFromBoardPoint = (
1162
+ boardPoint: Point,
1163
+ sourceScreenId: string,
1164
+ ) => {
1165
+ crossScreenLastBoardPointRef.current = boardPoint;
1166
+ setCrossScreenGhost({ boardX: boardPoint.x, boardY: boardPoint.y });
1167
+ const target = getFrameEntryAtPoint(boardPoint);
1168
+ if (target && target.id !== sourceScreenId) {
1169
+ const nextTarget = { id: target.id, geometry: target.geometry };
1170
+ crossScreenTargetRef.current = nextTarget;
1171
+ setCrossScreenTarget(nextTarget);
1172
+ } else {
1173
+ crossScreenTargetRef.current = null;
1174
+ setCrossScreenTarget(null);
1175
+ }
1176
+ };
1177
+
1178
+ const finalizeCrossScreenDrop = (
1179
+ sourceScreenId: string,
1180
+ candidate: CrossScreenDragTarget | null,
1181
+ payload: { selector: string; sourceId?: string },
1182
+ lastBoardPoint: Point | null,
1183
+ ) => {
1184
+ clearCrossScreenDrag();
1185
+ crossScreenLastBoardPointRef.current = null;
1186
+ const hasIdentifier = !!(payload.selector || payload.sourceId);
1187
+ if (!candidate || !hasIdentifier || !sourceScreenId) return;
1188
+
1189
+ const runHitTest = (): Promise<{
1190
+ anchorNodeId?: string;
1191
+ placement?: "before" | "after" | "inside";
1192
+ }> => {
1193
+ if (!lastBoardPoint) return Promise.resolve({});
1194
+ const targetScreen = screensRef.current.find(
1195
+ (s) => s.id === candidate.id,
1196
+ );
1197
+ if (!targetScreen) return Promise.resolve({});
1198
+ const targetGeometry = candidate.geometry;
1199
+ const targetIframe =
1200
+ surfaceRef.current?.querySelector<HTMLIFrameElement>(
1201
+ `[data-screen-iframe-id="${CSS.escape(candidate.id)}"]`,
1202
+ );
1203
+ const targetContentWindow = targetIframe?.contentWindow;
1204
+ if (!targetContentWindow) return Promise.resolve({});
1205
+ const targetViewportWidth =
1206
+ targetIframe.clientWidth || getResolvedMetadata(targetScreen).width;
1207
+ const targetViewportHeight =
1208
+ targetIframe.clientHeight || getResolvedMetadata(targetScreen).height;
1209
+ const scaleX = targetViewportWidth / Math.max(1, targetGeometry.width);
1210
+ const scaleY =
1211
+ targetViewportHeight / Math.max(1, targetGeometry.height);
1212
+ const localX = (lastBoardPoint.x - targetGeometry.x) * scaleX;
1213
+ const localY = (lastBoardPoint.y - targetGeometry.y) * scaleY;
1214
+
1215
+ const correlationId = `hit-${Date.now()}-${Math.random()
1216
+ .toString(36)
1217
+ .slice(2, 6)}`;
1218
+
1219
+ return new Promise((resolve) => {
1220
+ const timer = window.setTimeout(() => {
1221
+ window.removeEventListener("message", hitListener);
1222
+ resolve({});
1223
+ }, 50);
1224
+
1225
+ const hitListener = (ev: MessageEvent) => {
1226
+ if (
1227
+ !ev.data ||
1228
+ ev.data.type !== "agent-native:hit-test-result" ||
1229
+ ev.data.correlationId !== correlationId
1230
+ ) {
1231
+ return;
1232
+ }
1233
+ window.clearTimeout(timer);
1234
+ window.removeEventListener("message", hitListener);
1235
+ resolve({
1236
+ anchorNodeId: ev.data.anchorNodeId ?? undefined,
1237
+ placement: ev.data.placement ?? undefined,
1238
+ });
1239
+ };
1240
+ window.addEventListener("message", hitListener);
1241
+
1242
+ targetContentWindow.postMessage(
1243
+ {
1244
+ type: "agent-native:hit-test",
1245
+ correlationId,
1246
+ x: localX,
1247
+ y: localY,
1248
+ },
1249
+ "*",
1250
+ );
1251
+ });
1252
+ };
1253
+
1254
+ void runHitTest().then(({ anchorNodeId, placement }) => {
1255
+ onCrossScreenElementDropRef.current?.({
1256
+ sourceSelector: payload.selector,
1257
+ sourceNodeId: payload.sourceId,
1258
+ sourceScreenId,
1259
+ targetScreenId: candidate.id,
1260
+ targetAnchorNodeId: anchorNodeId,
1261
+ targetAnchorPlacement: placement,
1262
+ });
1263
+ });
1264
+ };
1265
+
1154
1266
  const handleMessage = (event: MessageEvent) => {
1155
1267
  if (!event.data || event.data.type !== "agent-native:cross-screen-drag") {
1156
1268
  return;
1157
1269
  }
1158
1270
  const msg = event.data as {
1159
1271
  type: string;
1160
- phase: "move" | "end" | "cancel";
1272
+ phase: "start" | "move" | "end" | "cancel";
1273
+ screenId?: string;
1161
1274
  selector?: string;
1162
1275
  sourceId?: string;
1163
1276
  iframeX?: number;
@@ -1171,9 +1284,13 @@ export function MultiScreenCanvas({
1171
1284
  return;
1172
1285
  }
1173
1286
 
1174
- // The drag originates from the active screen (only the active screen has
1175
- // a live interactive iframe posting these messages).
1176
- const sourceScreenId = activeId;
1287
+ // Prefer the iframe-supplied source id. In overview, activeId can point
1288
+ // at a different screen than the layer currently being dragged.
1289
+ const sourceScreenId =
1290
+ msg.screenId &&
1291
+ (msg.screenId === boardFileId || frameGeometryRef.current[msg.screenId])
1292
+ ? msg.screenId
1293
+ : activeId;
1177
1294
  if (!sourceScreenId) {
1178
1295
  // Always clear visual state (ghost + highlight) when we have no active
1179
1296
  // screen to attribute the drag to — regardless of the phase. Without
@@ -1183,6 +1300,62 @@ export function MultiScreenCanvas({
1183
1300
  return;
1184
1301
  }
1185
1302
 
1303
+ if (msg.phase === "start") {
1304
+ crossScreenDragMsgRef.current = {
1305
+ selector: msg.selector ?? "",
1306
+ sourceId: msg.sourceId,
1307
+ };
1308
+ stopParentCrossScreenDrag();
1309
+ const restorePreviewPointerEvents = mutePreviewIframePointerEvents(
1310
+ surfaceRef.current,
1311
+ );
1312
+ let didCleanup = false;
1313
+ const activateParentDrag = (ev: MouseEvent) => {
1314
+ ev.preventDefault();
1315
+ updateCrossScreenTargetFromBoardPoint(
1316
+ getCanvasPoint(ev.clientX, ev.clientY),
1317
+ sourceScreenId,
1318
+ );
1319
+ };
1320
+ const handleParentMouseMove = (ev: MouseEvent) => {
1321
+ activateParentDrag(ev);
1322
+ };
1323
+ const handleParentMouseUp = (ev: MouseEvent) => {
1324
+ activateParentDrag(ev);
1325
+ const candidate = crossScreenTargetRef.current;
1326
+ const payload = crossScreenDragMsgRef.current ?? {
1327
+ selector: msg.selector ?? "",
1328
+ sourceId: msg.sourceId,
1329
+ };
1330
+ const lastBoardPoint = crossScreenLastBoardPointRef.current;
1331
+ finalizeCrossScreenDrop(
1332
+ sourceScreenId,
1333
+ candidate,
1334
+ payload,
1335
+ lastBoardPoint,
1336
+ );
1337
+ };
1338
+ const handleParentWindowBlur = () => {
1339
+ clearCrossScreenDrag();
1340
+ };
1341
+ const cleanup = () => {
1342
+ if (didCleanup) return;
1343
+ didCleanup = true;
1344
+ window.removeEventListener("mousemove", handleParentMouseMove, true);
1345
+ window.removeEventListener("mouseup", handleParentMouseUp, true);
1346
+ window.removeEventListener("blur", handleParentWindowBlur, true);
1347
+ restorePreviewPointerEvents();
1348
+ if (crossScreenParentDragCleanupRef.current === cleanup) {
1349
+ crossScreenParentDragCleanupRef.current = null;
1350
+ }
1351
+ };
1352
+ crossScreenParentDragCleanupRef.current = cleanup;
1353
+ window.addEventListener("mousemove", handleParentMouseMove, true);
1354
+ window.addEventListener("mouseup", handleParentMouseUp, true);
1355
+ window.addEventListener("blur", handleParentWindowBlur, true);
1356
+ return;
1357
+ }
1358
+
1186
1359
  if (msg.phase === "move") {
1187
1360
  const { iframeX, iframeY, viewportW, viewportH, selector, sourceId } =
1188
1361
  msg;
@@ -1208,14 +1381,16 @@ export function MultiScreenCanvas({
1208
1381
  iframeX <= viewportW &&
1209
1382
  iframeY <= viewportH
1210
1383
  ) {
1211
- clearCrossScreenDrag();
1384
+ setCrossScreenGhost(null);
1385
+ setCrossScreenTarget(null);
1386
+ crossScreenTargetRef.current = null;
1387
+ crossScreenLastBoardPointRef.current = null;
1212
1388
  return;
1213
1389
  }
1214
1390
 
1215
- // Translate iframe coords → board coords using source frame geometry.
1216
- // Board math mirrors primitiveLocalToBoardRect:
1217
- // boardX = frame.x + iframeX * (frame.width / metadata.width)
1218
- // boardY = frame.y + iframeY * (frame.height / metadata.height)
1391
+ // Translate iframe coords → board coords using the live embedded
1392
+ // viewport from the bridge. In overview, the iframe viewport may be the
1393
+ // frame geometry rather than the screen metadata width.
1219
1394
 
1220
1395
  let boardX: number;
1221
1396
  let boardY: number;
@@ -1234,31 +1409,13 @@ export function MultiScreenCanvas({
1234
1409
  clearCrossScreenDrag();
1235
1410
  return;
1236
1411
  }
1237
- const sourceMetadata = getResolvedMetadata(sourceScreen);
1238
- const scaleX =
1239
- sourceGeometry.width / Math.max(1, sourceMetadata.width);
1240
- const scaleY =
1241
- sourceGeometry.height / Math.max(1, sourceMetadata.height);
1412
+ const scaleX = sourceGeometry.width / Math.max(1, viewportW);
1413
+ const scaleY = sourceGeometry.height / Math.max(1, viewportH);
1242
1414
  boardX = sourceGeometry.x + iframeX * scaleX;
1243
1415
  boardY = sourceGeometry.y + iframeY * scaleY;
1244
1416
  }
1245
1417
  const boardPoint = { x: boardX, y: boardY };
1246
-
1247
- // Remember the latest board-space position for use in the "end" handler.
1248
- crossScreenLastBoardPointRef.current = boardPoint;
1249
-
1250
- setCrossScreenGhost({ boardX, boardY });
1251
-
1252
- // Find which screen frame the board point falls in.
1253
- const target = getFrameEntryAtPoint(boardPoint);
1254
- if (target && target.id !== sourceScreenId) {
1255
- const nextTarget = { id: target.id, geometry: target.geometry };
1256
- crossScreenTargetRef.current = nextTarget;
1257
- setCrossScreenTarget(nextTarget);
1258
- } else {
1259
- crossScreenTargetRef.current = null;
1260
- setCrossScreenTarget(null);
1261
- }
1418
+ updateCrossScreenTargetFromBoardPoint(boardPoint, sourceScreenId);
1262
1419
  return;
1263
1420
  }
1264
1421
 
@@ -1273,97 +1430,18 @@ export function MultiScreenCanvas({
1273
1430
  sourceId: msg.sourceId,
1274
1431
  };
1275
1432
  const lastBoardPoint = crossScreenLastBoardPointRef.current;
1276
- clearCrossScreenDrag();
1277
- crossScreenLastBoardPointRef.current = null;
1278
- // Guard: require at least one stable identifier (selector or sourceId)
1279
- // so the drop handler in DesignEditor can resolve the node. An empty
1280
- // selector AND a missing sourceId would produce nodeAttrId="" and
1281
- // moveNodeBetweenDocuments would fail with a generic error toast.
1282
- const hasIdentifier = !!(payload.selector || payload.sourceId);
1283
- if (candidate && hasIdentifier && sourceScreenId) {
1284
- // Translate the board-space drop point to the target iframe's local
1285
- // coordinate space, then run a lightweight hit-test (postMessage +
1286
- // 50 ms timeout) to find the deepest container at the drop site.
1287
- const runHitTest = (): Promise<{
1288
- anchorNodeId?: string;
1289
- placement?: "before" | "after" | "inside";
1290
- }> => {
1291
- if (!lastBoardPoint) return Promise.resolve({});
1292
- const targetScreen = screensRef.current.find(
1293
- (s) => s.id === candidate.id,
1294
- );
1295
- if (!targetScreen) return Promise.resolve({});
1296
- const targetGeometry = candidate.geometry;
1297
- const targetMetadata = getResolvedMetadata(targetScreen);
1298
- // Board → target iframe local coords
1299
- const scaleX =
1300
- targetMetadata.width / Math.max(1, targetGeometry.width);
1301
- const scaleY =
1302
- targetMetadata.height / Math.max(1, targetGeometry.height);
1303
- const localX = (lastBoardPoint.x - targetGeometry.x) * scaleX;
1304
- const localY = (lastBoardPoint.y - targetGeometry.y) * scaleY;
1305
-
1306
- const targetIframe =
1307
- surfaceRef.current?.querySelector<HTMLIFrameElement>(
1308
- `[data-screen-iframe-id="${CSS.escape(candidate.id)}"]`,
1309
- );
1310
- const targetContentWindow = targetIframe?.contentWindow;
1311
- if (!targetContentWindow) return Promise.resolve({});
1312
-
1313
- const correlationId = `hit-${Date.now()}-${Math.random()
1314
- .toString(36)
1315
- .slice(2, 6)}`;
1316
-
1317
- return new Promise((resolve) => {
1318
- const timer = window.setTimeout(() => {
1319
- window.removeEventListener("message", hitListener);
1320
- resolve({});
1321
- }, 50);
1322
-
1323
- const hitListener = (ev: MessageEvent) => {
1324
- if (
1325
- !ev.data ||
1326
- ev.data.type !== "agent-native:hit-test-result" ||
1327
- ev.data.correlationId !== correlationId
1328
- )
1329
- return;
1330
- window.clearTimeout(timer);
1331
- window.removeEventListener("message", hitListener);
1332
- resolve({
1333
- anchorNodeId: ev.data.anchorNodeId ?? undefined,
1334
- placement: ev.data.placement ?? undefined,
1335
- });
1336
- };
1337
- window.addEventListener("message", hitListener);
1338
-
1339
- targetContentWindow.postMessage(
1340
- {
1341
- type: "agent-native:hit-test",
1342
- correlationId,
1343
- x: localX,
1344
- y: localY,
1345
- },
1346
- "*",
1347
- );
1348
- });
1349
- };
1350
-
1351
- void runHitTest().then(({ anchorNodeId, placement }) => {
1352
- onCrossScreenElementDropRef.current?.({
1353
- sourceSelector: payload.selector,
1354
- sourceNodeId: payload.sourceId,
1355
- sourceScreenId,
1356
- targetScreenId: candidate.id,
1357
- targetAnchorNodeId: anchorNodeId,
1358
- targetAnchorPlacement: placement,
1359
- });
1360
- });
1361
- }
1433
+ finalizeCrossScreenDrop(
1434
+ sourceScreenId,
1435
+ candidate,
1436
+ payload,
1437
+ lastBoardPoint,
1438
+ );
1362
1439
  }
1363
1440
  };
1364
1441
 
1365
1442
  window.addEventListener("message", handleMessage);
1366
1443
  return () => {
1444
+ stopParentCrossScreenDrag();
1367
1445
  window.removeEventListener("message", handleMessage);
1368
1446
  };
1369
1447
  }, [
@@ -1371,6 +1449,7 @@ export function MultiScreenCanvas({
1371
1449
  boardFileId,
1372
1450
  boardFrameGeometry,
1373
1451
  getFrameEntryAtPoint,
1452
+ getCanvasPoint,
1374
1453
  getResolvedMetadata,
1375
1454
  onCrossScreenElementDrop,
1376
1455
  ]);
@@ -1940,6 +2019,7 @@ export function MultiScreenCanvas({
1940
2019
  }, [frameGeometry, retryPersistedDraftPrimitives, screens]);
1941
2020
 
1942
2021
  const clearActivePenPath = useCallback(() => {
2022
+ activePenPathRef.current = null;
1943
2023
  setActivePenPath(null);
1944
2024
  setPenGesturePreview(null);
1945
2025
  setPenPointer(null);
@@ -1965,6 +2045,25 @@ export function MultiScreenCanvas({
1965
2045
  [clearActivePenPath, commitDraftPrimitive, onActiveToolChange, toolProps],
1966
2046
  );
1967
2047
 
2048
+ const undoActivePenPathSegment = useCallback(() => {
2049
+ const path = activePenPathRef.current;
2050
+ if (!path) return false;
2051
+
2052
+ const remainingNodes = path.nodes.slice(0, -1);
2053
+ if (remainingNodes.length === 0) {
2054
+ clearActivePenPath();
2055
+ return true;
2056
+ }
2057
+
2058
+ const nextPath: PenPath = { nodes: remainingNodes, closed: false };
2059
+ activePenPathRef.current = nextPath;
2060
+ setActivePenPath(nextPath);
2061
+ setPenGesturePreview(null);
2062
+ setPenPointer(null);
2063
+ setPenCloseHover(false);
2064
+ return true;
2065
+ }, [clearActivePenPath]);
2066
+
1968
2067
  const getPenAnchorPoint = useCallback(
1969
2068
  (
1970
2069
  clientX: number,
@@ -3615,15 +3714,21 @@ export function MultiScreenCanvas({
3615
3714
  useEffect(() => {
3616
3715
  const handleKeyDown = (event: KeyboardEvent) => {
3617
3716
  const path = activePenPathRef.current;
3618
- if (
3619
- !path ||
3620
- event.metaKey ||
3621
- event.ctrlKey ||
3622
- isEditableHotkeyTarget(event.target)
3623
- ) {
3717
+ if (!path || isEditableHotkeyTarget(event.target)) {
3624
3718
  return;
3625
3719
  }
3626
3720
 
3721
+ const primaryKey = event.metaKey || event.ctrlKey;
3722
+ if (primaryKey && !event.shiftKey && event.key.toLowerCase() === "z") {
3723
+ event.preventDefault();
3724
+ event.stopPropagation();
3725
+ event.stopImmediatePropagation();
3726
+ undoActivePenPathSegment();
3727
+ return;
3728
+ }
3729
+
3730
+ if (primaryKey) return;
3731
+
3627
3732
  if (event.key === "Enter") {
3628
3733
  event.preventDefault();
3629
3734
  event.stopPropagation();
@@ -3644,14 +3749,7 @@ export function MultiScreenCanvas({
3644
3749
  event.preventDefault();
3645
3750
  event.stopPropagation();
3646
3751
  event.stopImmediatePropagation();
3647
- const remainingNodes = path.nodes.slice(0, -1);
3648
- if (remainingNodes.length === 0) {
3649
- clearActivePenPath();
3650
- return;
3651
- }
3652
- setActivePenPath({ nodes: remainingNodes, closed: false });
3653
- setPenPointer(null);
3654
- setPenCloseHover(false);
3752
+ undoActivePenPathSegment();
3655
3753
  }
3656
3754
  };
3657
3755
 
@@ -3659,7 +3757,7 @@ export function MultiScreenCanvas({
3659
3757
  return () => {
3660
3758
  window.removeEventListener("keydown", handleKeyDown, true);
3661
3759
  };
3662
- }, [clearActivePenPath, finishPenPath]);
3760
+ }, [clearActivePenPath, finishPenPath, undoActivePenPathSegment]);
3663
3761
 
3664
3762
  useEffect(() => {
3665
3763
  const tool = normalizeCanvasTool(activeTool ?? localActiveTool);