@agent-native/core 0.81.2 → 0.81.3

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 (52) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +19 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/frame.ts +55 -0
  5. package/corpus/core/src/mcp/build-server.ts +30 -4
  6. package/corpus/core/src/mcp/embed-app.ts +33 -18
  7. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +8 -1
  8. package/corpus/templates/design/app/components/design/LayersPanel.tsx +11 -2
  9. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -16
  10. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +21 -14
  11. package/corpus/templates/design/app/pages/DesignEditor.tsx +557 -211
  12. package/corpus/templates/design/changelog/2026-06-30-design-studio-canvas-layers-can-be-copied-pasted-and-reparen.md +6 -0
  13. package/corpus/templates/design/e2e/global-setup.ts +3 -1
  14. package/corpus/templates/design/e2e/helpers.ts +4 -1
  15. package/corpus/templates/design/playwright.config.ts +4 -1
  16. package/corpus/templates/plan/actions/consume-plan-feedback.ts +1 -9
  17. package/corpus/templates/plan/actions/convert-visual-plan-to-prototype.ts +1 -1
  18. package/corpus/templates/plan/actions/create-plan-design.ts +1 -1
  19. package/corpus/templates/plan/actions/create-prototype-plan.ts +1 -1
  20. package/corpus/templates/plan/actions/create-ui-plan.ts +1 -1
  21. package/corpus/templates/plan/actions/create-visual-plan.ts +36 -19
  22. package/corpus/templates/plan/actions/create-visual-questions.ts +1 -1
  23. package/corpus/templates/plan/actions/create-visual-recap.ts +1 -1
  24. package/corpus/templates/plan/actions/delete-plan-comment.ts +1 -9
  25. package/corpus/templates/plan/actions/delete-visual-plan.ts +1 -9
  26. package/corpus/templates/plan/actions/get-plan-feedback.ts +1 -9
  27. package/corpus/templates/plan/actions/get-visual-plan.ts +1 -9
  28. package/corpus/templates/plan/actions/import-visual-plan-source.ts +1 -1
  29. package/corpus/templates/plan/actions/list-visual-plans.ts +1 -9
  30. package/corpus/templates/plan/actions/reply-to-plan-comment.ts +1 -9
  31. package/corpus/templates/plan/actions/resolve-plan-comment.ts +1 -9
  32. package/corpus/templates/plan/actions/update-visual-plan.ts +1 -1
  33. package/corpus/templates/plan/actions/visual-answer.ts +1 -1
  34. package/corpus/templates/plan/app/components/layout/Layout.tsx +15 -0
  35. package/corpus/templates/plan/app/entry.client.tsx +10 -0
  36. package/corpus/templates/plan/app/global.css +28 -0
  37. package/corpus/templates/plan/shared/plan-content.ts +10 -6
  38. package/dist/client/frame.d.ts.map +1 -1
  39. package/dist/client/frame.js +59 -0
  40. package/dist/client/frame.js.map +1 -1
  41. package/dist/collab/awareness.d.ts +2 -2
  42. package/dist/collab/awareness.d.ts.map +1 -1
  43. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  44. package/dist/mcp/build-server.d.ts.map +1 -1
  45. package/dist/mcp/build-server.js +31 -4
  46. package/dist/mcp/build-server.js.map +1 -1
  47. package/dist/mcp/embed-app.d.ts.map +1 -1
  48. package/dist/mcp/embed-app.js +33 -18
  49. package/dist/mcp/embed-app.js.map +1 -1
  50. package/dist/notifications/routes.d.ts +3 -3
  51. package/dist/observability/routes.d.ts +5 -5
  52. package/package.json +1 -1
@@ -778,6 +778,18 @@ interface CodingHandoffResult {
778
778
  fileCount?: number;
779
779
  }
780
780
 
781
+ interface CanvasLayerClipboardEntry {
782
+ html: string;
783
+ rootNodeId?: string;
784
+ sourceFileId: string;
785
+ }
786
+
787
+ interface SelectedCanvasLayerSnapshot extends CanvasLayerClipboardEntry {
788
+ node: CodeLayerNode;
789
+ sourceIndex: number;
790
+ tree: CodeLayerTreeNode[];
791
+ }
792
+
781
793
  export interface MotionTimelineRow {
782
794
  id: string | null;
783
795
  designId: string;
@@ -1351,7 +1363,12 @@ function normalizedDesignFileType(
1351
1363
 
1352
1364
  function nextBlankScreenFilename(files: DesignFile[]): string {
1353
1365
  const existing = new Set(files.map((file) => file.filename));
1354
- let index = files.length + 1;
1366
+ const screenCount = files.filter(
1367
+ (file) =>
1368
+ normalizedDesignFileType(file.fileType) === "html" &&
1369
+ !isBoardFile(file.filename),
1370
+ ).length;
1371
+ let index = screenCount + 1;
1355
1372
  let candidate = `screen-${index}.html`;
1356
1373
  while (existing.has(candidate)) {
1357
1374
  index += 1;
@@ -1461,6 +1478,9 @@ function appendCanvasPrimitiveToHtml(
1461
1478
  const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
1462
1479
  const path = doc.createElementNS("http://www.w3.org/2000/svg", "path");
1463
1480
  const markerId = `${nodeId}-arrow`;
1481
+ const explicitPathData = primitive.pathData?.trim()
1482
+ ? primitive.pathData
1483
+ : null;
1464
1484
  const points = primitive.points?.length
1465
1485
  ? primitive.points
1466
1486
  : [
@@ -1471,7 +1491,7 @@ function appendCanvasPrimitiveToHtml(
1471
1491
  const originY = Math.min(...points.map((point) => point.y));
1472
1492
  path.setAttribute(
1473
1493
  "d",
1474
- primitive.pathData ??
1494
+ explicitPathData ??
1475
1495
  points
1476
1496
  .map((point, index) => {
1477
1497
  const command = index === 0 ? "M" : "L";
@@ -1518,7 +1538,13 @@ function appendCanvasPrimitiveToHtml(
1518
1538
  }
1519
1539
  svg.setAttribute("data-agent-native-node-id", nodeId);
1520
1540
  svg.setAttribute("data-agent-native-layer-name", layerName);
1521
- svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
1541
+ svg.setAttribute("data-an-primitive", primitive.kind);
1542
+ svg.setAttribute(
1543
+ "viewBox",
1544
+ explicitPathData
1545
+ ? `${left} ${top} ${width} ${height}`
1546
+ : `0 0 ${width} ${height}`,
1547
+ );
1522
1548
  svg.setAttribute(
1523
1549
  "style",
1524
1550
  [
@@ -1690,44 +1716,126 @@ function cloneHtmlLayerAtPosition(
1690
1716
  layerHtml: string,
1691
1717
  position: { x: number; y: number },
1692
1718
  ): string | null {
1693
- if (typeof window === "undefined") return null;
1694
- try {
1695
- const doc = new DOMParser().parseFromString(content, "text/html");
1696
- const layerDoc = new DOMParser().parseFromString(
1697
- `<template>${layerHtml}</template>`,
1698
- "text/html",
1699
- );
1700
- const source =
1701
- layerDoc.querySelector("template")?.content.firstElementChild ??
1702
- layerDoc.body.firstElementChild;
1703
- if (!source || !doc.body) return null;
1704
- const clone = doc.importNode(source, true) as HTMLElement | SVGElement;
1705
- const clonedNodes = [
1706
- clone,
1707
- ...Array.from(clone.querySelectorAll("[data-agent-native-node-id]")),
1708
- ] as Array<HTMLElement | SVGElement>;
1709
- clonedNodes.forEach((node, index) => {
1719
+ return (
1720
+ insertClonedHtmlLayers(content, [layerHtml], {
1721
+ positions: [position],
1722
+ })?.content ?? null
1723
+ );
1724
+ }
1725
+
1726
+ function styleHost(element: Element): (HTMLElement | SVGElement) | null {
1727
+ return element instanceof HTMLElement || element instanceof SVGElement
1728
+ ? element
1729
+ : null;
1730
+ }
1731
+
1732
+ function clearRootLayerPosition(element: Element) {
1733
+ const host = styleHost(element);
1734
+ if (!host) return;
1735
+ host.style.position = "";
1736
+ host.style.left = "";
1737
+ host.style.top = "";
1738
+ host.style.right = "";
1739
+ host.style.bottom = "";
1740
+ }
1741
+
1742
+ function setRootLayerPosition(
1743
+ element: Element,
1744
+ position: { x: number; y: number },
1745
+ ) {
1746
+ const host = styleHost(element);
1747
+ if (!host) return;
1748
+ // Use explicit style property assignments rather than prepending a raw
1749
+ // string. Prepending creates duplicate CSS properties in the same style
1750
+ // attribute, and in CSS the LAST occurrence wins, so existing left/top
1751
+ // values from the cloned element would override the new position.
1752
+ host.style.position = "absolute";
1753
+ host.style.left = `${Math.max(0, Math.round(position.x))}px`;
1754
+ host.style.top = `${Math.max(0, Math.round(position.y))}px`;
1755
+ host.style.right = "";
1756
+ host.style.bottom = "";
1757
+ }
1758
+
1759
+ function prepareClonedHtmlLayer(
1760
+ doc: Document,
1761
+ layerHtml: string,
1762
+ ): { element: Element; rootNodeId: string } | null {
1763
+ const layerDoc = new DOMParser().parseFromString(
1764
+ `<template>${layerHtml}</template>`,
1765
+ "text/html",
1766
+ );
1767
+ const source =
1768
+ layerDoc.querySelector("template")?.content.firstElementChild ??
1769
+ layerDoc.body.firstElementChild;
1770
+ if (!source) return null;
1771
+ const clone = doc.importNode(source, true) as Element;
1772
+ const rootNodeId = uniqueLayerId("copy");
1773
+ clone.setAttribute("data-agent-native-node-id", rootNodeId);
1774
+ Array.from(clone.querySelectorAll("[data-agent-native-node-id]")).forEach(
1775
+ (node) => {
1710
1776
  node.setAttribute(
1711
1777
  "data-agent-native-node-id",
1712
- uniqueLayerId(index === 0 ? "copy" : "copy-child"),
1778
+ uniqueLayerId("copy-child"),
1713
1779
  );
1780
+ },
1781
+ );
1782
+ return { element: clone, rootNodeId };
1783
+ }
1784
+
1785
+ function insertClonedHtmlLayers(
1786
+ content: string,
1787
+ layerHtmls: string[],
1788
+ options: {
1789
+ targetSelectors?: string[];
1790
+ anchorSelectors?: string[];
1791
+ placement?: "before" | "after" | "inside";
1792
+ stripRootPosition?: boolean;
1793
+ positions?: Array<{ x: number; y: number } | null | undefined>;
1794
+ } = {},
1795
+ ): { content: string; rootNodeIds: string[] } | null {
1796
+ if (typeof window === "undefined" || layerHtmls.length === 0) return null;
1797
+ try {
1798
+ const doc = new DOMParser().parseFromString(content, "text/html");
1799
+ if (!doc.body) return null;
1800
+ const fragment = doc.createDocumentFragment();
1801
+ const rootNodeIds: string[] = [];
1802
+ layerHtmls.forEach((layerHtml, index) => {
1803
+ const prepared = prepareClonedHtmlLayer(doc, layerHtml);
1804
+ if (!prepared) return;
1805
+ const position = options.positions?.[index];
1806
+ if (position) {
1807
+ setRootLayerPosition(prepared.element, position);
1808
+ } else if (options.stripRootPosition) {
1809
+ clearRootLayerPosition(prepared.element);
1810
+ }
1811
+ rootNodeIds.push(prepared.rootNodeId);
1812
+ fragment.appendChild(prepared.element);
1714
1813
  });
1715
- if (clone instanceof HTMLElement || clone instanceof SVGElement) {
1716
- // Use explicit style property assignments rather than prepending a raw
1717
- // string. Prepending creates duplicate CSS properties in the same style
1718
- // attribute, and in CSS the LAST occurrence wins, so existing left/top
1719
- // values from the cloned element would override the new position.
1720
- const cloneStyle = (clone as HTMLElement | SVGElement).style;
1721
- cloneStyle.position = "absolute";
1722
- cloneStyle.left = `${Math.max(0, Math.round(position.x))}px`;
1723
- cloneStyle.top = `${Math.max(0, Math.round(position.y))}px`;
1724
- // Clear conflicting properties that could shift the element away from
1725
- // the intended position.
1726
- cloneStyle.right = "";
1727
- cloneStyle.bottom = "";
1814
+ if (rootNodeIds.length === 0) return null;
1815
+
1816
+ const target = queryFirstSelector(doc, options.targetSelectors ?? []);
1817
+ const anchor =
1818
+ queryFirstSelector(doc, options.anchorSelectors ?? []) ?? target;
1819
+ const placement = options.placement ?? "after";
1820
+ if (!anchor) {
1821
+ doc.body.appendChild(fragment);
1822
+ } else if (placement === "inside") {
1823
+ anchor.appendChild(fragment);
1824
+ } else if (placement === "before") {
1825
+ if (anchor.parentElement)
1826
+ anchor.parentElement.insertBefore(fragment, anchor);
1827
+ else doc.body.appendChild(fragment);
1828
+ } else {
1829
+ if (anchor.parentElement) {
1830
+ anchor.parentElement.insertBefore(fragment, anchor.nextSibling);
1831
+ } else {
1832
+ doc.body.appendChild(fragment);
1833
+ }
1728
1834
  }
1729
- doc.body.appendChild(clone);
1730
- return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
1835
+ return {
1836
+ content: `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`,
1837
+ rootNodeIds,
1838
+ };
1731
1839
  } catch {
1732
1840
  return null;
1733
1841
  }
@@ -1771,52 +1879,13 @@ function insertClonedHtmlLayer(
1771
1879
  placement?: "before" | "after" | "inside";
1772
1880
  },
1773
1881
  ): string | null {
1774
- if (typeof window === "undefined") return null;
1775
- try {
1776
- const doc = new DOMParser().parseFromString(content, "text/html");
1777
- const layerDoc = new DOMParser().parseFromString(
1778
- `<template>${cloneHtml}</template>`,
1779
- "text/html",
1780
- );
1781
- const source =
1782
- layerDoc.querySelector("template")?.content.firstElementChild ??
1783
- layerDoc.body.firstElementChild;
1784
- if (!source || !doc.body) return null;
1785
- const clone = doc.importNode(source, true) as HTMLElement | SVGElement;
1786
- const clonedNodes = [
1787
- clone,
1788
- ...Array.from(clone.querySelectorAll("[data-agent-native-node-id]")),
1789
- ] as Array<HTMLElement | SVGElement>;
1790
- clonedNodes.forEach((node, index) => {
1791
- node.setAttribute(
1792
- "data-agent-native-node-id",
1793
- uniqueLayerId(index === 0 ? "copy" : "copy-child"),
1794
- );
1795
- });
1796
-
1797
- const target = queryFirstSelector(doc, options.targetSelectors);
1798
- const anchor =
1799
- queryFirstSelector(doc, options.anchorSelectors ?? []) ?? target;
1800
- const placement = options.placement ?? "after";
1801
- if (!anchor) {
1802
- doc.body.appendChild(clone);
1803
- } else if (placement === "inside") {
1804
- anchor.appendChild(clone);
1805
- } else if (placement === "before") {
1806
- if (anchor.parentElement)
1807
- anchor.parentElement.insertBefore(clone, anchor);
1808
- else doc.body.appendChild(clone);
1809
- } else {
1810
- if (anchor.parentElement) {
1811
- anchor.parentElement.insertBefore(clone, anchor.nextSibling);
1812
- } else {
1813
- doc.body.appendChild(clone);
1814
- }
1815
- }
1816
- return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
1817
- } catch {
1818
- return null;
1819
- }
1882
+ return (
1883
+ insertClonedHtmlLayers(content, [cloneHtml], {
1884
+ targetSelectors: options.targetSelectors,
1885
+ anchorSelectors: options.anchorSelectors,
1886
+ placement: options.placement,
1887
+ })?.content ?? null
1888
+ );
1820
1889
  }
1821
1890
 
1822
1891
  function getElementOuterHtml(content: string, selector: string): string | null {
@@ -1858,6 +1927,55 @@ function extractLayerPosition(
1858
1927
  }
1859
1928
  }
1860
1929
 
1930
+ function postBeginTextEditToPreviewIframes(
1931
+ screenId: string | null,
1932
+ nodeId: string,
1933
+ ): boolean {
1934
+ if (typeof document === "undefined" || !nodeId) return false;
1935
+ const iframes = Array.from(
1936
+ document.querySelectorAll<HTMLIFrameElement>(
1937
+ "iframe[data-design-preview-iframe]",
1938
+ ),
1939
+ );
1940
+ const targetIframes = iframes.filter(
1941
+ (iframe) => screenId && iframe.dataset.screenIframeId === screenId,
1942
+ );
1943
+ const orderedIframes = targetIframes.length > 0 ? targetIframes : iframes;
1944
+ const selector = `[data-agent-native-node-id="${nodeId.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"][data-agent-native-text-editing]`;
1945
+ if (
1946
+ orderedIframes.some((iframe) => {
1947
+ try {
1948
+ return iframe.contentDocument?.querySelector(selector);
1949
+ } catch {
1950
+ return false;
1951
+ }
1952
+ })
1953
+ ) {
1954
+ return true;
1955
+ }
1956
+ orderedIframes.forEach((iframe) => {
1957
+ iframe.contentWindow?.postMessage(
1958
+ { type: "begin-text-edit", nodeId, force: true },
1959
+ "*",
1960
+ );
1961
+ });
1962
+ return false;
1963
+ }
1964
+
1965
+ function scheduleBeginTextEditForScreen(
1966
+ screenId: string | null,
1967
+ nodeId: string,
1968
+ ) {
1969
+ if (typeof window === "undefined") return;
1970
+ let sawEditing = false;
1971
+ [50, 150, 300, 600, 900, 1200, 1800, 2400, 3200, 4200].forEach((delay) => {
1972
+ window.setTimeout(() => {
1973
+ if (sawEditing) return;
1974
+ sawEditing = postBeginTextEditToPreviewIframes(screenId, nodeId);
1975
+ }, delay);
1976
+ });
1977
+ }
1978
+
1861
1979
  function removeElementFromHtml(
1862
1980
  content: string,
1863
1981
  selector: string,
@@ -3640,6 +3758,7 @@ export default function DesignEditor() {
3640
3758
  useState(0);
3641
3759
  const [hasCanvasClipboard, setHasCanvasClipboard] = useState(false);
3642
3760
  const [hasPropsClipboard, setHasPropsClipboard] = useState(false);
3761
+ const copiedLayerEntriesRef = useRef<CanvasLayerClipboardEntry[]>([]);
3643
3762
  const copiedLayerHtmlRef = useRef<string | null>(null);
3644
3763
  // Cascade offset for repeated keyboard pastes so successive clones don't stack
3645
3764
  // pixel-perfectly on top of each other. Reset on each fresh copy/cut.
@@ -7205,9 +7324,7 @@ export default function DesignEditor() {
7205
7324
  // enter text-edit mode — fixing the "click to add text should let me
7206
7325
  // type immediately" bug. The ref is read once and cleared.
7207
7326
  if (primitive.kind === "text") {
7208
- const textNodeId =
7209
- typeof result === "string" ? result : (primitive.nodeId ?? null);
7210
- pendingTextEditNodeIdRef.current = textNodeId;
7327
+ pendingTextEditNodeIdRef.current = primitive.nodeId ?? null;
7211
7328
  } else {
7212
7329
  pendingTextEditNodeIdRef.current = null;
7213
7330
  }
@@ -7252,17 +7369,15 @@ export default function DesignEditor() {
7252
7369
  });
7253
7370
  // viewMode stays at "overview" — no setViewMode("single") call here.
7254
7371
 
7255
- // Immediately enter text-editing for newly created TEXT primitives so the
7256
- // user can start typing without a second click (fixes auto-size flow too).
7257
- // We read-and-clear the ref set by handleCreatePrimitive just above. The
7258
- // 50 ms delay gives React a tick to commit the new content into the active
7259
- // screen's iframe before the bridge tries to find the element by nodeId.
7260
- const textNodeId = pendingTextEditNodeIdRef.current;
7372
+ // Immediately enter text-editing for newly created TEXT primitives. In
7373
+ // overview mode the target iframe may become active and receive the
7374
+ // inserted HTML over separate renders, so post directly to the target
7375
+ // iframe with a few short retries instead of relying on a single global
7376
+ // bridge callback.
7377
+ const textNodeId = pendingTextEditNodeIdRef.current ?? nodeId;
7261
7378
  pendingTextEditNodeIdRef.current = null;
7262
7379
  if (textNodeId) {
7263
- setTimeout(() => {
7264
- (window as any).__designCanvasBeginTextEdit?.(textNodeId);
7265
- }, 50);
7380
+ scheduleBeginTextEditForScreen(screenId, textNodeId);
7266
7381
  }
7267
7382
  },
7268
7383
  [clearPendingOverviewLayerSelectionTimer],
@@ -7281,28 +7396,14 @@ export default function DesignEditor() {
7281
7396
  if (!result) return false;
7282
7397
 
7283
7398
  // For TEXT primitives drawn on the board surface, immediately enter
7284
- // text-editing mode via begin-text-edit. The board DesignCanvas uses
7285
- // registerRuntimeBridge=false so window.__designCanvasBeginTextEdit is
7286
- // not set; instead we broadcast the message to all preview iframes — the
7287
- // one that contains the new nodeId will handle it, others ignore it.
7288
- // The 50 ms delay lets the board iframe receive its content update first.
7399
+ // text-editing mode via begin-text-edit. The target is the board file,
7400
+ // so use the same iframe-targeted retry path as screen primitives.
7289
7401
  if (primitive.kind === "text") {
7290
7402
  const textNodeId =
7291
7403
  pendingTextEditNodeIdRef.current ?? primitive.nodeId ?? null;
7292
7404
  pendingTextEditNodeIdRef.current = null;
7293
7405
  if (textNodeId) {
7294
- setTimeout(() => {
7295
- document
7296
- .querySelectorAll<HTMLIFrameElement>(
7297
- "iframe[data-design-preview-iframe]",
7298
- )
7299
- .forEach((iframe) => {
7300
- iframe.contentWindow?.postMessage(
7301
- { type: "begin-text-edit", nodeId: textNodeId },
7302
- "*",
7303
- );
7304
- });
7305
- }, 50);
7406
+ scheduleBeginTextEditForScreen(boardFileId, textNodeId);
7306
7407
  }
7307
7408
  }
7308
7409
 
@@ -8994,27 +9095,167 @@ export default function DesignEditor() {
8994
9095
  ],
8995
9096
  );
8996
9097
 
8997
- const handleCopySelection = useCallback(async () => {
8998
- if (!selectedElement?.selector) return;
8999
- const html = getElementOuterHtml(
9000
- getFreshActiveContent(),
9001
- selectedElement.selector,
9098
+ const getSelectedLayerSnapshots = useCallback(() => {
9099
+ const fileIds = new Set(files.map((file) => file.id));
9100
+ const candidateIds = selectedLayerIdsState.filter(
9101
+ (layerId) =>
9102
+ layerId && !layerId.startsWith("__") && !fileIds.has(layerId),
9002
9103
  );
9003
- if (!html) return;
9004
- copiedLayerHtmlRef.current = html;
9104
+ if (
9105
+ selectedElementLayerId &&
9106
+ !candidateIds.includes(selectedElementLayerId)
9107
+ ) {
9108
+ candidateIds.push(selectedElementLayerId);
9109
+ }
9110
+
9111
+ const snapshots: SelectedCanvasLayerSnapshot[] = [];
9112
+ for (const file of files) {
9113
+ const content = getScreenContent(file.id);
9114
+ if (!content) continue;
9115
+ const projection = buildCodeLayerProjection(content);
9116
+ const tree = buildCodeLayerTree(projection);
9117
+ for (const layerId of candidateIds) {
9118
+ const node = projection.nodes.find(
9119
+ (candidate) =>
9120
+ candidate.id === layerId ||
9121
+ candidate.dataAttributes["data-agent-native-node-id"] === layerId,
9122
+ );
9123
+ if (!node?.source) continue;
9124
+ snapshots.push({
9125
+ html: content.slice(node.source.start, node.source.end),
9126
+ rootNodeId:
9127
+ node.dataAttributes["data-agent-native-node-id"] ?? node.id,
9128
+ sourceFileId: file.id,
9129
+ node,
9130
+ sourceIndex: node.source.start,
9131
+ tree,
9132
+ });
9133
+ }
9134
+ }
9135
+
9136
+ if (snapshots.length === 0 && activeFile && selectedElement?.selector) {
9137
+ const content = getFreshActiveContent();
9138
+ const projection = buildCodeLayerProjection(content);
9139
+ const tree = buildCodeLayerTree(projection);
9140
+ const node = resolveCodeLayerNodeFromElementInfo(
9141
+ projection,
9142
+ selectedElement,
9143
+ );
9144
+ const html = node?.source
9145
+ ? content.slice(node.source.start, node.source.end)
9146
+ : getElementOuterHtml(content, selectedElement.selector);
9147
+ if (html && node) {
9148
+ snapshots.push({
9149
+ html,
9150
+ rootNodeId:
9151
+ node.dataAttributes["data-agent-native-node-id"] ??
9152
+ selectedElement.sourceId ??
9153
+ selectedElement.id,
9154
+ sourceFileId: activeFile.id,
9155
+ node,
9156
+ sourceIndex: node.source?.start ?? Number.MAX_SAFE_INTEGER,
9157
+ tree,
9158
+ });
9159
+ }
9160
+ }
9161
+
9162
+ const selectedKeys = new Set(
9163
+ snapshots.map(
9164
+ (snapshot) => `${snapshot.sourceFileId}:${snapshot.node.id}`,
9165
+ ),
9166
+ );
9167
+ const topLevelSnapshots = snapshots.filter(
9168
+ (snapshot) =>
9169
+ !collectCodeLayerAncestors(snapshot.tree, snapshot.node.id).some(
9170
+ (ancestorId) =>
9171
+ selectedKeys.has(`${snapshot.sourceFileId}:${ancestorId}`),
9172
+ ),
9173
+ );
9174
+ const fileOrder = new Map(files.map((file, index) => [file.id, index]));
9175
+ return topLevelSnapshots.sort((a, b) => {
9176
+ const fileDelta =
9177
+ (fileOrder.get(a.sourceFileId) ?? 0) -
9178
+ (fileOrder.get(b.sourceFileId) ?? 0);
9179
+ if (fileDelta !== 0) return fileDelta;
9180
+ return a.sourceIndex - b.sourceIndex;
9181
+ });
9182
+ }, [
9183
+ activeFile,
9184
+ files,
9185
+ getFreshActiveContent,
9186
+ getScreenContent,
9187
+ selectedElement,
9188
+ selectedElementLayerId,
9189
+ selectedLayerIdsState,
9190
+ ]);
9191
+
9192
+ const getCanvasClipboardEntries = useCallback(() => {
9193
+ if (copiedLayerEntriesRef.current.length > 0) {
9194
+ return copiedLayerEntriesRef.current;
9195
+ }
9196
+ return copiedLayerHtmlRef.current
9197
+ ? [
9198
+ {
9199
+ html: copiedLayerHtmlRef.current,
9200
+ sourceFileId: activeFile?.id ?? "",
9201
+ },
9202
+ ]
9203
+ : [];
9204
+ }, [activeFile?.id]);
9205
+
9206
+ const selectInsertedLayers = useCallback(
9207
+ (screenId: string, content: string, rootNodeIds: string[]) => {
9208
+ const projection = buildCodeLayerProjection(content);
9209
+ const insertedNodes = rootNodeIds
9210
+ .map((rootNodeId) =>
9211
+ projection.nodes.find(
9212
+ (node) =>
9213
+ node.id === rootNodeId ||
9214
+ node.dataAttributes["data-agent-native-node-id"] === rootNodeId,
9215
+ ),
9216
+ )
9217
+ .filter((node): node is CodeLayerNode => Boolean(node));
9218
+ if (insertedNodes.length === 0) return;
9219
+ setActiveFileId(screenId);
9220
+ setSelectedLayerIdsState(insertedNodes.map((node) => node.id));
9221
+ const lastNode = insertedNodes[insertedNodes.length - 1];
9222
+ setSelectedElement(
9223
+ lastNode ? elementInfoFromCodeLayerNode(lastNode) : null,
9224
+ );
9225
+ setActiveTool("move");
9226
+ setMode("edit");
9227
+ if (viewModeRef.current === "overview") {
9228
+ setOverviewSelectedScreenIds([screenId]);
9229
+ }
9230
+ },
9231
+ [],
9232
+ );
9233
+
9234
+ const handleCopySelection = useCallback(async () => {
9235
+ const entries = getSelectedLayerSnapshots().map((snapshot) => ({
9236
+ html: snapshot.html,
9237
+ rootNodeId: snapshot.rootNodeId,
9238
+ sourceFileId: snapshot.sourceFileId,
9239
+ }));
9240
+ if (entries.length === 0) return;
9241
+ const clipboardText = entries.map((entry) => entry.html).join("\n");
9242
+ copiedLayerEntriesRef.current = entries;
9243
+ copiedLayerHtmlRef.current = clipboardText;
9005
9244
  pasteCascadeRef.current = 0;
9006
9245
  setHasCanvasClipboard(true);
9007
9246
  try {
9008
- await navigator.clipboard.writeText(html);
9247
+ await navigator.clipboard.writeText(clipboardText);
9009
9248
  } catch {
9010
9249
  toast.error(t("designEditor.toasts.clipboardBlocked"));
9011
9250
  }
9012
- }, [getFreshActiveContent, selectedElement, t]);
9251
+ }, [getSelectedLayerSnapshots, t]);
9013
9252
 
9014
9253
  const handlePasteSelection = useCallback(
9015
9254
  (position?: { x: number; y: number }) => {
9016
- if (!activeFile || !canEditDesign || !copiedLayerHtmlRef.current) return;
9255
+ const entries = getCanvasClipboardEntries();
9256
+ if (!activeFile || !canEditDesign || entries.length === 0) return;
9017
9257
  const baseContent = getFreshActiveContent();
9258
+ const layerHtmls = entries.map((entry) => entry.html);
9018
9259
 
9019
9260
  // B7 fix: when an element is selected and no explicit canvas position was
9020
9261
  // given, insert the clone as an in-flow sibling right AFTER the selected
@@ -9024,38 +9265,19 @@ export default function DesignEditor() {
9024
9265
  // selected or a "Paste here" position is provided.
9025
9266
  if (!position && selectedElement?.selector) {
9026
9267
  const selector = selectedCanvasSelector ?? selectedElement.selector;
9027
- // Strip position properties from the pasted clone so it becomes an
9028
- // in-flow sibling (not absolute).
9029
- const strippedHtml = (() => {
9030
- try {
9031
- const parser = new DOMParser();
9032
- const tmp = parser.parseFromString(
9033
- `<template>${copiedLayerHtmlRef.current!}</template>`,
9034
- "text/html",
9035
- );
9036
- const root =
9037
- tmp.querySelector("template")?.content.firstElementChild ??
9038
- tmp.body.firstElementChild;
9039
- if (root && root instanceof HTMLElement) {
9040
- root.style.position = "";
9041
- root.style.left = "";
9042
- root.style.top = "";
9043
- root.style.right = "";
9044
- root.style.bottom = "";
9045
- }
9046
- return root?.outerHTML ?? copiedLayerHtmlRef.current!;
9047
- } catch {
9048
- return copiedLayerHtmlRef.current!;
9049
- }
9050
- })();
9051
-
9052
- const nextContent = insertClonedHtmlLayer(baseContent, strippedHtml, {
9268
+ const result = insertClonedHtmlLayers(baseContent, layerHtmls, {
9053
9269
  targetSelectors: [selector],
9054
9270
  placement: "after",
9271
+ stripRootPosition: true,
9055
9272
  });
9056
- if (nextContent) {
9273
+ if (result) {
9057
9274
  pasteCascadeRef.current += 1;
9058
- applyLocalContentUpdate(nextContent);
9275
+ applyLocalContentUpdate(result.content);
9276
+ selectInsertedLayers(
9277
+ activeFile.id,
9278
+ result.content,
9279
+ result.rootNodeIds,
9280
+ );
9059
9281
  return;
9060
9282
  }
9061
9283
  // Fall through to position-based clone if insert failed.
@@ -9064,59 +9286,154 @@ export default function DesignEditor() {
9064
9286
  // Explicit positions (e.g. "Paste here" at the cursor) are honored as-is.
9065
9287
  // Keyboard pastes land near the source layer and cascade so repeats don't
9066
9288
  // stack exactly.
9067
- const targetPosition =
9068
- position ??
9069
- (() => {
9070
- const src = extractLayerPosition(copiedLayerHtmlRef.current!);
9071
- const offset = pasteCascadeRef.current * 16;
9072
- return src
9073
- ? { x: src.x + 10 + offset, y: src.y + 10 + offset }
9074
- : { x: 120 + offset, y: 120 + offset };
9075
- })();
9076
- const nextContent = cloneHtmlLayerAtPosition(
9077
- baseContent,
9078
- copiedLayerHtmlRef.current,
9079
- targetPosition,
9289
+ const sourcePositions = entries.map((entry) =>
9290
+ extractLayerPosition(entry.html),
9291
+ );
9292
+ const positionedSources = sourcePositions.filter(
9293
+ (source): source is { x: number; y: number } => Boolean(source),
9080
9294
  );
9081
- if (!nextContent) return;
9295
+ const minSourceX = positionedSources.length
9296
+ ? Math.min(...positionedSources.map((source) => source.x))
9297
+ : 0;
9298
+ const minSourceY = positionedSources.length
9299
+ ? Math.min(...positionedSources.map((source) => source.y))
9300
+ : 0;
9301
+ const cascadeOffset = pasteCascadeRef.current * 16;
9302
+ const positions = entries.map((_, index) => {
9303
+ const source = sourcePositions[index];
9304
+ if (position) {
9305
+ return source && positionedSources.length
9306
+ ? {
9307
+ x: position.x + source.x - minSourceX,
9308
+ y: position.y + source.y - minSourceY,
9309
+ }
9310
+ : { x: position.x + index * 16, y: position.y + index * 16 };
9311
+ }
9312
+ return source
9313
+ ? {
9314
+ x: source.x + 10 + cascadeOffset,
9315
+ y: source.y + 10 + cascadeOffset,
9316
+ }
9317
+ : {
9318
+ x: 120 + cascadeOffset + index * 16,
9319
+ y: 120 + cascadeOffset + index * 16,
9320
+ };
9321
+ });
9322
+ const result = insertClonedHtmlLayers(baseContent, layerHtmls, {
9323
+ positions,
9324
+ });
9325
+ if (!result) return;
9082
9326
  if (!position) pasteCascadeRef.current += 1;
9083
- applyLocalContentUpdate(nextContent);
9327
+ applyLocalContentUpdate(result.content);
9328
+ selectInsertedLayers(activeFile.id, result.content, result.rootNodeIds);
9084
9329
  },
9085
9330
  [
9086
9331
  activeFile,
9087
9332
  applyLocalContentUpdate,
9088
9333
  canEditDesign,
9334
+ getCanvasClipboardEntries,
9089
9335
  getFreshActiveContent,
9336
+ selectInsertedLayers,
9090
9337
  selectedCanvasSelector,
9091
9338
  selectedElement,
9092
9339
  ],
9093
9340
  );
9094
9341
 
9095
9342
  const handlePasteOverSelection = useCallback(() => {
9096
- if (!activeFile || !copiedLayerHtmlRef.current) return;
9343
+ const entries = getCanvasClipboardEntries();
9344
+ if (!activeFile || entries.length === 0) return;
9097
9345
  const baseContent = getFreshActiveContent();
9098
9346
  if (selectedElement?.boundingRect) {
9099
9347
  const { x, y } = selectedElement.boundingRect;
9100
- const nextContent = cloneHtmlLayerAtPosition(
9348
+ const result = insertClonedHtmlLayers(
9101
9349
  baseContent,
9102
- copiedLayerHtmlRef.current,
9103
- { x, y },
9350
+ entries.map((entry) => entry.html),
9351
+ {
9352
+ positions: entries.map((_, index) => ({
9353
+ x: x + index * 16,
9354
+ y: y + index * 16,
9355
+ })),
9356
+ },
9104
9357
  );
9105
- if (!nextContent) return;
9106
- applyLocalContentUpdate(nextContent);
9358
+ if (!result) return;
9359
+ applyLocalContentUpdate(result.content);
9360
+ selectInsertedLayers(activeFile.id, result.content, result.rootNodeIds);
9107
9361
  } else {
9108
9362
  handlePasteSelection();
9109
9363
  }
9110
9364
  }, [
9111
9365
  activeFile,
9112
9366
  applyLocalContentUpdate,
9367
+ getCanvasClipboardEntries,
9113
9368
  getFreshActiveContent,
9114
9369
  handlePasteSelection,
9370
+ selectInsertedLayers,
9115
9371
  selectedElement,
9116
9372
  ]);
9117
9373
 
9118
9374
  const handleDuplicateSelection = useCallback(() => {
9119
9375
  if (!canEditDesign) return;
9376
+ const snapshots = getSelectedLayerSnapshots();
9377
+ if (snapshots.length > 0) {
9378
+ const selectedIds: string[] = [];
9379
+ const selectedScreenIds: string[] = [];
9380
+ let lastActiveNode: CodeLayerNode | null = null;
9381
+
9382
+ for (const file of files) {
9383
+ const group = snapshots.filter(
9384
+ (snapshot) => snapshot.sourceFileId === file.id,
9385
+ );
9386
+ if (group.length === 0) continue;
9387
+ let content = getScreenContent(file.id);
9388
+ const insertedRootNodeIds: string[] = [];
9389
+ for (const snapshot of [...group].sort(
9390
+ (a, b) => b.sourceIndex - a.sourceIndex,
9391
+ )) {
9392
+ const projection = buildCodeLayerProjection(content);
9393
+ const anchorNode =
9394
+ projection.nodes.find(
9395
+ (node) =>
9396
+ node.id === snapshot.node.id ||
9397
+ node.dataAttributes["data-agent-native-node-id"] ===
9398
+ snapshot.rootNodeId,
9399
+ ) ?? snapshot.node;
9400
+ const result = insertClonedHtmlLayers(content, [snapshot.html], {
9401
+ targetSelectors: codeLayerSelectorAliases(anchorNode),
9402
+ placement: "after",
9403
+ stripRootPosition: true,
9404
+ });
9405
+ if (!result) continue;
9406
+ content = result.content;
9407
+ insertedRootNodeIds.unshift(...result.rootNodeIds);
9408
+ }
9409
+ if (insertedRootNodeIds.length === 0) continue;
9410
+ applyFileContentUpdate(file.id, content, { refreshPreview: false });
9411
+ selectedScreenIds.push(file.id);
9412
+ const finalProjection = buildCodeLayerProjection(content);
9413
+ insertedRootNodeIds.forEach((rootNodeId) => {
9414
+ const insertedNode = finalProjection.nodes.find(
9415
+ (node) =>
9416
+ node.id === rootNodeId ||
9417
+ node.dataAttributes["data-agent-native-node-id"] === rootNodeId,
9418
+ );
9419
+ if (!insertedNode) return;
9420
+ selectedIds.push(insertedNode.id);
9421
+ if (file.id === activeFile?.id) lastActiveNode = insertedNode;
9422
+ });
9423
+ }
9424
+
9425
+ if (selectedIds.length > 0) {
9426
+ setSelectedLayerIdsState(selectedIds);
9427
+ setSelectedElement(
9428
+ lastActiveNode ? elementInfoFromCodeLayerNode(lastActiveNode) : null,
9429
+ );
9430
+ if (viewModeRef.current === "overview") {
9431
+ setOverviewSelectedScreenIds(selectedScreenIds);
9432
+ }
9433
+ return;
9434
+ }
9435
+ }
9436
+
9120
9437
  if (selectedElement?.selector) {
9121
9438
  const baseContent = getFreshActiveContent();
9122
9439
  const html = getElementOuterHtml(baseContent, selectedElement.selector);
@@ -9164,9 +9481,13 @@ export default function DesignEditor() {
9164
9481
  if (activeFile) handleDuplicateScreen(activeFile.id);
9165
9482
  }, [
9166
9483
  activeFile,
9484
+ applyFileContentUpdate,
9167
9485
  applyLocalContentUpdate,
9168
9486
  canEditDesign,
9487
+ files,
9169
9488
  getFreshActiveContent,
9489
+ getScreenContent,
9490
+ getSelectedLayerSnapshots,
9170
9491
  handleDuplicateScreen,
9171
9492
  selectedCanvasSelector,
9172
9493
  selectedElement,
@@ -9175,65 +9496,89 @@ export default function DesignEditor() {
9175
9496
 
9176
9497
  const handleDeleteSelection = useCallback(() => {
9177
9498
  if (!canEditDesign) return;
9178
- const baseContent = getFreshActiveContent();
9179
- // Multi-select delete: when several DOM/code layers are selected in the
9180
- // panel, remove all of them — not just the single focused element. Compose
9181
- // the removals against the running content (re-projecting each pass) so
9182
- // nested selections resolve correctly and earlier removals aren't clobbered.
9183
- const candidateIds = selectedLayerIdsState.filter(
9184
- (layerId) =>
9185
- layerId &&
9186
- !layerId.startsWith("__") &&
9187
- !files.some((file) => file.id === layerId),
9188
- );
9189
- if (candidateIds.length > 1) {
9190
- let content = baseContent;
9191
- const removedSelectors: string[] = [];
9192
- for (const layerId of candidateIds) {
9499
+ const snapshots = getSelectedLayerSnapshots();
9500
+ if (snapshots.length > 0) {
9501
+ const activeRuntimeSelectors: string[] = [];
9502
+ let didDelete = false;
9503
+ for (const file of files) {
9504
+ const group = snapshots.filter(
9505
+ (snapshot) => snapshot.sourceFileId === file.id,
9506
+ );
9507
+ if (group.length === 0) continue;
9508
+ const originalContent = getScreenContent(file.id);
9509
+ let content = originalContent;
9193
9510
  const projection = buildCodeLayerProjection(content);
9194
- const node =
9195
- projection.nodes.find((candidate) => candidate.id === layerId) ??
9196
- resolveCodeLayerNodeFromBridge(projection, layerId, layerId);
9197
- if (!node) continue;
9198
- const next = removeCodeLayerNodeFromHtml(content, node);
9199
- if (!next) continue;
9200
- const selector = preferredCodeLayerSelector(node);
9201
- if (selector) removedSelectors.push(selector);
9202
- content = next;
9203
- }
9204
- if (content !== baseContent) {
9205
- removedSelectors.forEach((selector) => deleteRuntimeElement(selector));
9206
- applyLocalContentUpdate(content, { refreshPreview: false });
9207
- setSelectedElement(null);
9208
- setSelectedLayerIdsState([]);
9209
- return;
9511
+ const tree = buildCodeLayerTree(projection);
9512
+ const selectedNodeIds = new Set(
9513
+ group.map((snapshot) => snapshot.node.id),
9514
+ );
9515
+ const nodes = group
9516
+ .map((snapshot) =>
9517
+ projection.nodes.find(
9518
+ (node) =>
9519
+ node.id === snapshot.node.id ||
9520
+ node.dataAttributes["data-agent-native-node-id"] ===
9521
+ snapshot.rootNodeId,
9522
+ ),
9523
+ )
9524
+ .filter((node): node is CodeLayerNode => Boolean(node?.source))
9525
+ .filter(
9526
+ (node) =>
9527
+ !collectCodeLayerAncestors(tree, node.id).some((ancestorId) =>
9528
+ selectedNodeIds.has(ancestorId),
9529
+ ),
9530
+ )
9531
+ .sort((a, b) => (b.source?.start ?? 0) - (a.source?.start ?? 0));
9532
+ if (nodes.length === 0) continue;
9533
+ const removedSelectors: string[] = [];
9534
+ for (const node of nodes) {
9535
+ const next = removeCodeLayerNodeFromHtml(content, node);
9536
+ if (!next) continue;
9537
+ const selector = preferredCodeLayerSelector(node);
9538
+ if (selector) removedSelectors.push(selector);
9539
+ content = next;
9540
+ }
9541
+ if (content === originalContent) continue;
9542
+ if (file.id === activeFile?.id) {
9543
+ activeRuntimeSelectors.push(...removedSelectors);
9544
+ }
9545
+ didDelete = true;
9546
+ applyFileContentUpdate(file.id, content, { refreshPreview: false });
9547
+ }
9548
+ if (!didDelete) return;
9549
+ activeRuntimeSelectors.forEach((selector) =>
9550
+ deleteRuntimeElement(selector),
9551
+ );
9552
+ setSelectedElement(null);
9553
+ setSelectedLayerIdsState([]);
9554
+ if (viewModeRef.current === "overview") {
9555
+ setOverviewSelectedScreenIds([]);
9210
9556
  }
9211
- // Nothing resolved (stale ids) — fall through to the single path.
9557
+ return;
9212
9558
  }
9213
9559
 
9214
9560
  if (!selectedElement?.selector) return;
9215
- const projection = buildCodeLayerProjection(baseContent);
9216
- const targetNode = resolveCodeLayerNodeFromElementInfo(
9217
- projection,
9218
- selectedElement,
9561
+ const baseContent = getFreshActiveContent();
9562
+ const nextContent = removeElementFromHtml(
9563
+ baseContent,
9564
+ selectedElement.selector,
9219
9565
  );
9220
- const nextContent =
9221
- (targetNode
9222
- ? removeCodeLayerNodeFromHtml(baseContent, targetNode)
9223
- : null) ?? removeElementFromHtml(baseContent, selectedElement.selector);
9224
9566
  if (!nextContent) return;
9225
9567
  deleteRuntimeElement(selectedElement.selector);
9226
9568
  applyLocalContentUpdate(nextContent, { refreshPreview: false });
9227
9569
  setSelectedElement(null);
9228
9570
  setSelectedLayerIdsState([]);
9229
9571
  }, [
9572
+ activeFile?.id,
9573
+ applyFileContentUpdate,
9230
9574
  applyLocalContentUpdate,
9231
9575
  canEditDesign,
9232
9576
  deleteRuntimeElement,
9233
9577
  files,
9234
9578
  getFreshActiveContent,
9579
+ getScreenContent,
9580
+ getSelectedLayerSnapshots,
9235
9581
  selectedElement,
9236
- selectedLayerIdsState,
9237
9582
  ]);
9238
9583
 
9239
9584
  // Wrap the current multi-layer selection into a new group container.
@@ -14299,6 +14644,7 @@ ${serializedHtml}
14299
14644
  <DesignCanvas
14300
14645
  content={screenContent}
14301
14646
  contentKey={screenContentKey}
14647
+ screenId={screen.id}
14302
14648
  zoom={100}
14303
14649
  deviceFrame="none"
14304
14650
  sourceType={designSourceType}