@agent-native/core 0.114.2 → 0.114.4

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 (132) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +30 -0
  3. package/corpus/core/package.json +2 -1
  4. package/corpus/core/src/a2a/artifact-response.ts +42 -16
  5. package/corpus/core/src/a2a/client.ts +4 -0
  6. package/corpus/core/src/a2a/handlers.ts +133 -2
  7. package/corpus/core/src/a2a/index.ts +1 -0
  8. package/corpus/core/src/a2a/types.ts +14 -0
  9. package/corpus/core/src/db/request-telemetry.ts +32 -8
  10. package/corpus/core/src/integrations/index.ts +6 -0
  11. package/corpus/core/src/integrations/pending-tasks-store.ts +89 -0
  12. package/corpus/core/src/integrations/webhook-handler.ts +17 -7
  13. package/corpus/core/src/scripts/call-agent.ts +54 -10
  14. package/corpus/core/src/server/agent-discovery.ts +22 -0
  15. package/corpus/core/src/settings/store.ts +15 -6
  16. package/corpus/core/src/shared/optional-node-builtins.ts +66 -0
  17. package/corpus/core/src/templates/default/.agents/skills/feature-flags/SKILL.md +1 -1
  18. package/corpus/core/src/templates/headless/.agents/skills/feature-flags/SKILL.md +1 -1
  19. package/corpus/core/src/templates/workspace-core/.agents/skills/feature-flags/SKILL.md +1 -1
  20. package/corpus/core/src/vite/client.ts +5 -0
  21. package/corpus/templates/analytics/.agents/skills/feature-flags/SKILL.md +1 -1
  22. package/corpus/templates/assets/.agents/skills/feature-flags/SKILL.md +1 -1
  23. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +2 -2
  24. package/corpus/templates/brain/.agents/skills/feature-flags/SKILL.md +1 -1
  25. package/corpus/templates/calendar/.agents/skills/feature-flags/SKILL.md +1 -1
  26. package/corpus/templates/chat/.agents/skills/feature-flags/SKILL.md +1 -1
  27. package/corpus/templates/clips/.agents/skills/feature-flags/SKILL.md +1 -1
  28. package/corpus/templates/clips/shared/feature-flags.ts +1 -1
  29. package/corpus/templates/content/.agents/skills/content/SKILL.md +19 -5
  30. package/corpus/templates/content/.agents/skills/feature-flags/SKILL.md +1 -1
  31. package/corpus/templates/content/changelog/2026-07-17-slack-created-database-entries-now-record-slack-as-their-sub.md +6 -0
  32. package/corpus/templates/design/.agents/skills/feature-flags/SKILL.md +1 -1
  33. package/corpus/templates/design/actions/update-file.ts +10 -1
  34. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +30 -1
  35. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +14 -1
  36. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +547 -50
  37. package/corpus/templates/design/app/components/design/dnd-debug.ts +27 -0
  38. package/corpus/templates/design/app/components/design/multi-screen/draft-primitives.ts +19 -2
  39. package/corpus/templates/design/app/i18n-data.ts +19 -0
  40. package/corpus/templates/design/app/lib/design-save-outbox.ts +46 -2
  41. package/corpus/templates/design/app/pages/design-editor/canvas-primitive-insert.ts +4 -9
  42. package/corpus/templates/design/app/pages/design-editor/history.ts +14 -1
  43. package/corpus/templates/design/changelog/2026-07-17-drawn-shapes-stay-free-positioned-when-dragged.md +6 -0
  44. package/corpus/templates/design/changelog/2026-07-17-shapes-show-a-live-outline-while-you-draw.md +6 -0
  45. package/corpus/templates/design/shared/drag-reflow.ts +375 -0
  46. package/corpus/templates/design/shared/full-app.ts +1 -1
  47. package/corpus/templates/dispatch/.agents/skills/feature-flags/SKILL.md +1 -1
  48. package/corpus/templates/forms/.agents/skills/feature-flags/SKILL.md +1 -1
  49. package/corpus/templates/macros/.agents/skills/feature-flags/SKILL.md +1 -1
  50. package/corpus/templates/mail/.agents/skills/feature-flags/SKILL.md +1 -1
  51. package/corpus/templates/plan/.agents/skills/feature-flags/SKILL.md +1 -1
  52. package/corpus/templates/slides/.agents/skills/feature-flags/SKILL.md +1 -1
  53. package/corpus/templates/tasks/.agents/skills/feature-flags/SKILL.md +1 -1
  54. package/dist/a2a/artifact-response.d.ts +5 -0
  55. package/dist/a2a/artifact-response.d.ts.map +1 -1
  56. package/dist/a2a/artifact-response.js +20 -5
  57. package/dist/a2a/artifact-response.js.map +1 -1
  58. package/dist/a2a/client.d.ts +3 -1
  59. package/dist/a2a/client.d.ts.map +1 -1
  60. package/dist/a2a/client.js +2 -0
  61. package/dist/a2a/client.js.map +1 -1
  62. package/dist/a2a/handlers.d.ts.map +1 -1
  63. package/dist/a2a/handlers.js +100 -8
  64. package/dist/a2a/handlers.js.map +1 -1
  65. package/dist/a2a/index.d.ts +1 -1
  66. package/dist/a2a/index.d.ts.map +1 -1
  67. package/dist/a2a/index.js.map +1 -1
  68. package/dist/a2a/types.d.ts +12 -0
  69. package/dist/a2a/types.d.ts.map +1 -1
  70. package/dist/a2a/types.js.map +1 -1
  71. package/dist/collab/routes.d.ts +1 -1
  72. package/dist/collab/struct-routes.d.ts +1 -1
  73. package/dist/db/request-telemetry.d.ts.map +1 -1
  74. package/dist/db/request-telemetry.js +24 -6
  75. package/dist/db/request-telemetry.js.map +1 -1
  76. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  77. package/dist/integrations/index.d.ts +1 -0
  78. package/dist/integrations/index.d.ts.map +1 -1
  79. package/dist/integrations/index.js +1 -0
  80. package/dist/integrations/index.js.map +1 -1
  81. package/dist/integrations/pending-tasks-store.d.ts +9 -0
  82. package/dist/integrations/pending-tasks-store.d.ts.map +1 -1
  83. package/dist/integrations/pending-tasks-store.js +60 -0
  84. package/dist/integrations/pending-tasks-store.js.map +1 -1
  85. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  86. package/dist/integrations/webhook-handler.js +13 -7
  87. package/dist/integrations/webhook-handler.js.map +1 -1
  88. package/dist/notifications/routes.d.ts +2 -2
  89. package/dist/observability/routes.d.ts +3 -3
  90. package/dist/scripts/call-agent.d.ts.map +1 -1
  91. package/dist/scripts/call-agent.js +39 -10
  92. package/dist/scripts/call-agent.js.map +1 -1
  93. package/dist/secrets/routes.d.ts +9 -9
  94. package/dist/server/agent-discovery.d.ts +2 -0
  95. package/dist/server/agent-discovery.d.ts.map +1 -1
  96. package/dist/server/agent-discovery.js +19 -0
  97. package/dist/server/agent-discovery.js.map +1 -1
  98. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  99. package/dist/settings/store.d.ts +1 -1
  100. package/dist/settings/store.d.ts.map +1 -1
  101. package/dist/settings/store.js +14 -6
  102. package/dist/settings/store.js.map +1 -1
  103. package/dist/shared/optional-node-builtins.d.ts +23 -0
  104. package/dist/shared/optional-node-builtins.d.ts.map +1 -0
  105. package/dist/shared/optional-node-builtins.js +35 -0
  106. package/dist/shared/optional-node-builtins.js.map +1 -0
  107. package/dist/templates/chat/.agents/skills/feature-flags/SKILL.md +1 -1
  108. package/dist/templates/default/.agents/skills/feature-flags/SKILL.md +1 -1
  109. package/dist/templates/headless/.agents/skills/feature-flags/SKILL.md +1 -1
  110. package/dist/templates/workspace-core/.agents/skills/feature-flags/SKILL.md +1 -1
  111. package/dist/vite/client.d.ts.map +1 -1
  112. package/dist/vite/client.js +2 -0
  113. package/dist/vite/client.js.map +1 -1
  114. package/package.json +2 -1
  115. package/src/a2a/artifact-response.ts +42 -16
  116. package/src/a2a/client.ts +4 -0
  117. package/src/a2a/handlers.ts +133 -2
  118. package/src/a2a/index.ts +1 -0
  119. package/src/a2a/types.ts +14 -0
  120. package/src/db/request-telemetry.ts +32 -8
  121. package/src/integrations/index.ts +6 -0
  122. package/src/integrations/pending-tasks-store.ts +89 -0
  123. package/src/integrations/webhook-handler.ts +17 -7
  124. package/src/scripts/call-agent.ts +54 -10
  125. package/src/server/agent-discovery.ts +22 -0
  126. package/src/settings/store.ts +15 -6
  127. package/src/shared/optional-node-builtins.ts +66 -0
  128. package/src/templates/chat/.agents/skills/feature-flags/SKILL.md +1 -1
  129. package/src/templates/default/.agents/skills/feature-flags/SKILL.md +1 -1
  130. package/src/templates/headless/.agents/skills/feature-flags/SKILL.md +1 -1
  131. package/src/templates/workspace-core/.agents/skills/feature-flags/SKILL.md +1 -1
  132. package/src/vite/client.ts +5 -0
@@ -0,0 +1,27 @@
1
+ // Drag-and-drop debug logging (host side). Mirrors the bridge's own [dnd:*]
2
+ // console timeline (editor-chrome.bridge.ts) so a single gesture reads
3
+ // end-to-end across the prototype iframe and the parent React app.
4
+ //
5
+ // The iframe and the host are separate windows, so each has its OWN toggle:
6
+ // • bridge (in the iframe console): window.__DND_DEBUG = true
7
+ // • host (in the top-frame console): window.__DND_DEBUG = true
8
+ // Both default OFF (opt in at runtime). Host lines are cyan and prefixed
9
+ // [dnd:host:*]; bridge lines are purple and prefixed [dnd:*].
10
+ declare global {
11
+ interface Window {
12
+ __DND_DEBUG?: boolean;
13
+ }
14
+ }
15
+
16
+ export function dndHostLog(phase: string, data?: unknown): void {
17
+ if (typeof window === "undefined") return;
18
+ if (!window.__DND_DEBUG) return;
19
+ try {
20
+ const tag = `%c[dnd:host:${phase}]`;
21
+ const style = "color:#0ea5e9;font-weight:bold";
22
+ if (data === undefined) console.log(tag, style);
23
+ else console.log(tag, style, data);
24
+ } catch {
25
+ /* logging must never break a drag */
26
+ }
27
+ }
@@ -12,7 +12,7 @@ import {
12
12
 
13
13
  import { DEFAULT_LINE_STROKE_WIDTH_PX } from "../canvas-primitive-style";
14
14
  import { boardPointToScreenLocalPoint } from "./coordinate-transforms";
15
- import { getFrameCenter } from "./frame-geometry";
15
+ import { getFrameCenter, getScreenPreviewViewport } from "./frame-geometry";
16
16
  import type {
17
17
  CanvasPrimitiveInsert,
18
18
  DraftCreationPreview,
@@ -229,10 +229,27 @@ export function draftPrimitiveToInsert(
229
229
  frameGeometry: FrameGeometry,
230
230
  metadata?: ResolvedScreenMetadata,
231
231
  ): CanvasPrimitiveInsert {
232
- const viewport = {
232
+ const metadataViewport = {
233
233
  width: metadata?.width ?? frameGeometry.width,
234
234
  height: metadata?.height ?? frameGeometry.height,
235
235
  };
236
+ // A draw must serialize in the same viewport the content is laid out in.
237
+ // getScreenPreviewViewport is the source of truth: an inline screen renders at
238
+ // metadata dims and CSS-scales when the frame's aspect matches, but reflows to
239
+ // the frame when it differs (so the metadata 1280×2560 default no longer
240
+ // stretches shapes on a non-portrait frame). Fixed-viewport sources
241
+ // (localhost/fusion) always use their own metadata viewport.
242
+ const isInline = !metadata || metadata.source === "inline";
243
+ const previewViewport = getScreenPreviewViewport(metadataViewport, {
244
+ width: frameGeometry.width,
245
+ height: frameGeometry.height,
246
+ });
247
+ const viewport = isInline
248
+ ? {
249
+ width: previewViewport.viewportWidth,
250
+ height: previewViewport.viewportHeight,
251
+ }
252
+ : metadataViewport;
236
253
  const scaleX = viewport.width / Math.max(1, frameGeometry.width);
237
254
  const scaleY = viewport.height / Math.max(1, frameGeometry.height);
238
255
  const roundLocal = (value: number) => Math.round(value) || 0;
@@ -1191,6 +1191,8 @@ const enUS = {
1191
1191
  escToExit: "Esc to exit",
1192
1192
  tellAgentWhatToChange: "Tell the agent what to change…",
1193
1193
  changesSaveWhenReconnected: "Changes will save when reconnected",
1194
+ changesDiscarded:
1195
+ "Some changes couldn't be saved because the file no longer exists and were discarded.",
1194
1196
  offline: "Offline",
1195
1197
  saving: "Saving...",
1196
1198
  clearedAllAnnotations: "Cleared all annotations",
@@ -5458,6 +5460,7 @@ const designRawLiteralOverrides = {
5458
5460
  escToExit: "Esc 退出",
5459
5461
  tellAgentWhatToChange: "告诉代理要更改什么...",
5460
5462
  changesSaveWhenReconnected: "重新连接时将保存更改",
5463
+ changesDiscarded: "部分更改因文件不存在而无法保存,已被丢弃。",
5461
5464
  offline: "离线",
5462
5465
  saving: "正在保存...",
5463
5466
  clearedAllAnnotations: "已清除所有批注",
@@ -5618,6 +5621,8 @@ const designRawLiteralOverrides = {
5618
5621
  tellAgentWhatToChange: "Dígale al agente qué cambiar...",
5619
5622
  changesSaveWhenReconnected:
5620
5623
  "Los cambios se guardarán cuando se vuelva a conectar",
5624
+ changesDiscarded:
5625
+ "Algunos cambios no se pudieron guardar porque el archivo ya no existe y se descartaron.",
5621
5626
  offline: "Sin conexión",
5622
5627
  saving: "Guardando...",
5623
5628
  clearedAllAnnotations: "Se borraron todas las anotaciones",
@@ -5779,6 +5784,8 @@ const designRawLiteralOverrides = {
5779
5784
  tellAgentWhatToChange: "Dites à l’agent ce qu’il faut changer…",
5780
5785
  changesSaveWhenReconnected:
5781
5786
  "Les modifications seront enregistrées une fois reconnecté",
5787
+ changesDiscarded:
5788
+ "Certaines modifications n’ont pas pu être enregistrées car le fichier n’existe plus et ont été ignorées.",
5782
5789
  offline: "Hors ligne",
5783
5790
  saving: "Enregistrement...",
5784
5791
  clearedAllAnnotations: "Toutes les annotations ont été effacées",
@@ -5941,6 +5948,8 @@ const designRawLiteralOverrides = {
5941
5948
  tellAgentWhatToChange: "Sagen Sie dem Agenten, was er ändern soll ...",
5942
5949
  changesSaveWhenReconnected:
5943
5950
  "Änderungen werden gespeichert, wenn die Verbindung wiederhergestellt wird",
5951
+ changesDiscarded:
5952
+ "Einige Änderungen konnten nicht gespeichert werden, da die Datei nicht mehr existiert, und wurden verworfen.",
5944
5953
  offline: "Offline",
5945
5954
  saving: "Speichern...",
5946
5955
  clearedAllAnnotations: "Alle Anmerkungen gelöscht",
@@ -6099,6 +6108,8 @@ const designRawLiteralOverrides = {
6099
6108
  escToExit: "Esc 終了",
6100
6109
  tellAgentWhatToChange: "何を変更するかをエージェントに伝えてください...",
6101
6110
  changesSaveWhenReconnected: "変更は再接続時に保存されます",
6111
+ changesDiscarded:
6112
+ "ファイルが存在しないため、一部の変更を保存できず破棄しました。",
6102
6113
  offline: "オフライン",
6103
6114
  saving: "保存中...",
6104
6115
  clearedAllAnnotations: "すべての注釈を消去しました",
@@ -6255,6 +6266,8 @@ const designRawLiteralOverrides = {
6255
6266
  escToExit: "Esc 종료",
6256
6267
  tellAgentWhatToChange: "상담사에게 무엇을 변경해야 할지 알려주세요...",
6257
6268
  changesSaveWhenReconnected: "다시 연결되면 변경사항이 저장됩니다.",
6269
+ changesDiscarded:
6270
+ "파일이 더 이상 존재하지 않아 일부 변경사항을 저장하지 못하고 삭제했습니다.",
6258
6271
  offline: "오프라인",
6259
6272
  saving: "저장 중...",
6260
6273
  clearedAllAnnotations: "모든 주석이 지워졌습니다",
@@ -6415,6 +6428,8 @@ const designRawLiteralOverrides = {
6415
6428
  tellAgentWhatToChange: "Diga ao agente o que mudar…",
6416
6429
  changesSaveWhenReconnected:
6417
6430
  "As alterações serão salvas quando reconectadas",
6431
+ changesDiscarded:
6432
+ "Algumas alterações não puderam ser salvas porque o arquivo não existe mais e foram descartadas.",
6418
6433
  offline: "Off-line",
6419
6434
  saving: "Salvando...",
6420
6435
  clearedAllAnnotations: "Todas as anotações foram limpas",
@@ -6572,6 +6587,8 @@ const designRawLiteralOverrides = {
6572
6587
  escToExit: "बाहर निकलने के लिए Esc",
6573
6588
  tellAgentWhatToChange: "एजेंट को बताएं कि क्या बदलना है...",
6574
6589
  changesSaveWhenReconnected: "पुन: कनेक्ट होने पर परिवर्तन सहेजे जाएंगे",
6590
+ changesDiscarded:
6591
+ "कुछ परिवर्तन सहेजे नहीं जा सके क्योंकि फ़ाइल अब मौजूद नहीं है, और उन्हें छोड़ दिया गया।",
6575
6592
  offline: "ऑफ़लाइन",
6576
6593
  saving: "सहेजा जा रहा है...",
6577
6594
  clearedAllAnnotations: "सभी एनोटेशन साफ़ किए गए",
@@ -6728,6 +6745,8 @@ const designRawLiteralOverrides = {
6728
6745
  escToExit: "Esc للخروج",
6729
6746
  tellAgentWhatToChange: "أخبر الوكيل بما يجب تغييره...",
6730
6747
  changesSaveWhenReconnected: "سيتم حفظ التغييرات عند إعادة الاتصال",
6748
+ changesDiscarded:
6749
+ "تعذّر حفظ بعض التغييرات لأن الملف لم يعد موجودًا، وتم تجاهلها.",
6731
6750
  offline: "غير متصل",
6732
6751
  saving: "جارٍ الحفظ...",
6733
6752
  clearedAllAnnotations: "تم مسح جميع التعليقات التوضيحية",
@@ -29,6 +29,33 @@ export interface DesignSaveOutboxStorage {
29
29
  export interface DrainDesignSaveOutboxResult {
30
30
  saved: DesignSaveOutboxEntry[];
31
31
  failed: Array<{ entry: DesignSaveOutboxEntry; error: unknown }>;
32
+ /**
33
+ * Entries dropped because retrying can never succeed — the target file no
34
+ * longer exists (deleted or never created). Retained-and-retried forever they
35
+ * turn one orphaned screen into a 500 storm that jams every save; dropping
36
+ * them self-heals stale outbox residue. Separate from `failed`, which is for
37
+ * transient/conflict errors that SHOULD be retried.
38
+ */
39
+ dropped: Array<{ entry: DesignSaveOutboxEntry; error: unknown }>;
40
+ }
41
+
42
+ /**
43
+ * A save failure that can never succeed on retry: the server reports the target
44
+ * file is gone (HTTP 404, or a "File not found" message from update-file's
45
+ * missing-row guard). Distinct from 409 conflicts and network errors, which are
46
+ * transient and must stay queued.
47
+ */
48
+ export function isTerminalSaveError(error: unknown): boolean {
49
+ if (!error || typeof error !== "object") return false;
50
+ const candidate = error as { status?: unknown; message?: unknown };
51
+ // Terminal only when an explicit 404 ALSO names a missing file. A bare 404 can
52
+ // be a transient route-not-found (e.g. a cold-start action route), so both
53
+ // signals are required — dropping an edit is unrecoverable, a retry is not.
54
+ return (
55
+ candidate.status === 404 &&
56
+ typeof candidate.message === "string" &&
57
+ /file not found/i.test(candidate.message)
58
+ );
32
59
  }
33
60
 
34
61
  const DATABASE_NAME = "agent-native-design-save-outbox";
@@ -293,7 +320,11 @@ async function drainEntries(
293
320
  ) => Promise<unknown>,
294
321
  storage: DesignSaveOutboxStorage,
295
322
  ): Promise<DrainDesignSaveOutboxResult> {
296
- const result: DrainDesignSaveOutboxResult = { saved: [], failed: [] };
323
+ const result: DrainDesignSaveOutboxResult = {
324
+ saved: [],
325
+ failed: [],
326
+ dropped: [],
327
+ };
297
328
  for (const entry of await storage.list(designId, actorScope)) {
298
329
  try {
299
330
  if (
@@ -332,7 +363,20 @@ async function drainEntries(
332
363
  await storage.deleteIfRevision(entry);
333
364
  result.saved.push(entry);
334
365
  } catch (error) {
335
- result.failed.push({ entry, error });
366
+ if (isTerminalSaveError(error)) {
367
+ // The target file is gone (deleted/never created) — retrying can never
368
+ // succeed. Drop the entry so one orphaned screen can't loop update-file
369
+ // 500s forever and jam every save. Logged, never silently swallowed.
370
+ await storage.deleteIfRevision(entry);
371
+ result.dropped.push({ entry, error });
372
+ if (typeof console !== "undefined") {
373
+ console.warn(
374
+ `[design-save-outbox] dropped unrecoverable save for ${entry.actionName} ${entry.resourceId} (file no longer exists)`,
375
+ );
376
+ }
377
+ } else {
378
+ result.failed.push({ entry, error });
379
+ }
336
380
  }
337
381
  }
338
382
  return result;
@@ -62,6 +62,9 @@ export function nextBlankScreenFilename(files: DesignFile[]): string {
62
62
  export function blankScreenHtml(title: string): string {
63
63
  const safeTitle = escapeHtmlText(title);
64
64
  const safeTitleAttribute = escapeHtmlAttributeValue(title);
65
+ // Blank screen = free canvas: <body> is the positioned root and drawn shapes
66
+ // are absolute children (x,y in the HTML). A centering grid / <main> wrapper
67
+ // trapped shapes at center and got auto-layout-converted on drop.
65
68
  return `<!DOCTYPE html>
66
69
  <html lang="en">
67
70
  <head>
@@ -77,17 +80,9 @@ export function blankScreenHtml(title: string): string {
77
80
  color: var(--color-text, #111827);
78
81
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
79
82
  }
80
- main {
81
- min-height: 100vh;
82
- display: grid;
83
- place-items: center;
84
- padding: 48px;
85
- }
86
83
  </style>
87
84
  </head>
88
- <body>
89
- <main data-agent-native-layer-name="${safeTitleAttribute}">
90
- </main>
85
+ <body data-agent-native-layer-name="${safeTitleAttribute}">
91
86
  </body>
92
87
  </html>`;
93
88
  }
@@ -173,6 +173,11 @@ export interface ContentHistoryChange {
173
173
  fileId: string;
174
174
  before: string;
175
175
  after: string;
176
+ /** Agent-authored replacement checkpoint. Prevents the next user edit's
177
+ * fallback mirror from coalescing backward through this entry, which would
178
+ * collapse the agent state out of the undo stack and make a single Cmd+Z
179
+ * jump past all AI-generated content to the pre-agent baseline. */
180
+ isCheckpoint?: boolean;
176
181
  }
177
182
 
178
183
  export interface ContentHistoryGroup {
@@ -261,7 +266,15 @@ export function mergeLocalContentHistoryFallback(
261
266
  ): ContentHistoryChange[] {
262
267
  if (change.before === change.after) return stack;
263
268
  const last = stack[stack.length - 1];
264
- if (last && last.fileId === change.fileId && last.after === change.before) {
269
+ if (
270
+ last &&
271
+ last.fileId === change.fileId &&
272
+ last.after === change.before &&
273
+ // An incoming checkpoint must stay its own undo boundary, or one Cmd+Z
274
+ // reverts an agent edit together with the preceding user edit.
275
+ !last.isCheckpoint &&
276
+ !change.isCheckpoint
277
+ ) {
265
278
  return [...stack.slice(0, -1), { ...last, after: change.after }];
266
279
  }
267
280
  return [...stack.slice(-(MAX_DESIGN_UNDO_STACK - 1)), change];
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-17
4
+ ---
5
+
6
+ Drawn shapes now land exactly where and how big you dragged them — they no longer snap to center, get trapped inside a container, or stretch into a tall ribbon.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-17
4
+ ---
5
+
6
+ Shapes now show a live outline while you drag to draw them, even when drawing over an existing screen.
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Pure decision logic for Figma-parity in-screen drag & drop.
3
+ *
4
+ * This module is the typed, unit-tested source of truth for the drag
5
+ * behaviors added in the "live reflow" work (Phase 0 + Phase 1 of the
6
+ * auto-layout DnD plan). It is deliberately free of DOM access so it can be
7
+ * exercised directly by vitest.
8
+ *
9
+ * The editor bridge (`app/components/design/bridge/editor-chrome.bridge.ts`)
10
+ * cannot `import` anything — it is compiled to a self-contained IIFE by
11
+ * `bridge/codegen.ts`. So, exactly like `shared/canvas-math.ts` →
12
+ * `computeMoveSnapOffset`, the bridge carries a hand-ported copy of these
13
+ * functions. Keep the two in sync; this file is the reference implementation
14
+ * and the place where behavior is proven.
15
+ *
16
+ * Four independent concerns live here:
17
+ * 1. Hysteresis — stabilize the resolved drop target so the insertion
18
+ * preview never strobes on a boundary (Phase 0.1).
19
+ * 2. Size guard — refuse to auto-nest a large element into a smaller
20
+ * container, Figma-style (Phase 0.2).
21
+ * 3. Packed-container detection — decide whether a flex container is simple
22
+ * enough that live sibling reflow can be modeled by a constant slot
23
+ * shift; everything else falls back to an indicator only (Phase 1).
24
+ * 4. Reflow offsets — the per-sibling translate deltas that visually open /
25
+ * close a slot while dragging, for the packed case (Phase 1).
26
+ */
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // 1. Hysteresis
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Identity of a resolved drop target. `containerKey` is any stable string id
34
+ * for the target container (the bridge uses a per-drag element→key map);
35
+ * `index` is the insertion index within that container.
36
+ */
37
+ export interface DragTargetKey {
38
+ containerKey: string;
39
+ index: number;
40
+ }
41
+
42
+ /**
43
+ * A freshly resolved (raw) drop-target candidate for the current pointer
44
+ * position, plus the geometry the hysteresis gate needs to decide whether to
45
+ * accept it. All coordinates are in one consistent space (the bridge uses
46
+ * iframe-local client coordinates — see canvas-math note about the host
47
+ * scaling the whole iframe uniformly).
48
+ */
49
+ export interface CandidatePoint {
50
+ x: number;
51
+ y: number;
52
+ }
53
+
54
+ export interface DragTargetCandidate {
55
+ /** The resolved target, or null when the pointer is over no valid target. */
56
+ key: DragTargetKey | null;
57
+ /** Pointer position this tick, in a consistent space, for the movement
58
+ * deadband (distance from where the current target was committed). */
59
+ pointer: CandidatePoint;
60
+ /** How far the pointer has penetrated a *different* container from its
61
+ * entering edge, px. Pass Infinity for a same-container change. */
62
+ containerPenetrationPx: number;
63
+ /** True when the candidate container is an ancestor of the committed one
64
+ * (leaving toward a parent) — reverses instantly. */
65
+ isLeave: boolean;
66
+ }
67
+
68
+ export interface HysteresisState {
69
+ key: DragTargetKey;
70
+ committedAt: number;
71
+ committedPointer: CandidatePoint;
72
+ /** The differing candidate currently being timed out, and since when. */
73
+ pendingKey: DragTargetKey | null;
74
+ pendingAt: number;
75
+ }
76
+
77
+ export interface HysteresisOptions {
78
+ /** Pointer must move at least this far from the last commit before a
79
+ * same-container slot change is accepted. Default 8. */
80
+ movePx?: number;
81
+ /** …or the new candidate must persist this long (ms). Default 60. */
82
+ dwellMs?: number;
83
+ /** Pointer must penetrate a different container this far, px. Default 10. */
84
+ containerPenetrationPx?: number;
85
+ /** …or the new container must persist this long (ms). Default 80. */
86
+ containerDwellMs?: number;
87
+ }
88
+
89
+ export interface HysteresisResult {
90
+ key: DragTargetKey | null;
91
+ changed: boolean;
92
+ state: HysteresisState | null;
93
+ }
94
+
95
+ const DEFAULT_HYSTERESIS: Required<HysteresisOptions> = {
96
+ movePx: 8,
97
+ dwellMs: 60,
98
+ containerPenetrationPx: 10,
99
+ containerDwellMs: 80,
100
+ };
101
+
102
+ function keysEqual(a: DragTargetKey | null, b: DragTargetKey | null): boolean {
103
+ if (a === null || b === null) return a === b;
104
+ return a.containerKey === b.containerKey && a.index === b.index;
105
+ }
106
+
107
+ function commitTarget(
108
+ key: DragTargetKey,
109
+ pointer: CandidatePoint,
110
+ now: number,
111
+ ): HysteresisResult {
112
+ return {
113
+ key,
114
+ changed: true,
115
+ state: {
116
+ key,
117
+ committedAt: now,
118
+ committedPointer: pointer,
119
+ pendingKey: null,
120
+ pendingAt: 0,
121
+ },
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Stabilize a raw drop-target candidate against the committed one so the
127
+ * preview only transitions when the pointer deliberately moves to a new
128
+ * slot/container. A change is accepted when the pointer has moved `movePx`
129
+ * from the last commit (deliberate move) OR the *new* candidate has persisted
130
+ * `dwellMs` — dwell is timed from when the candidate first appeared, not from
131
+ * the committed target's age. Cross-container leaves reverse instantly; other
132
+ * container entries need penetration or dwell. Pure: `now` is passed in.
133
+ */
134
+ export function resolveTargetHysteresis(
135
+ prev: HysteresisState | null,
136
+ candidate: DragTargetCandidate,
137
+ now: number,
138
+ options: HysteresisOptions = {},
139
+ ): HysteresisResult {
140
+ const opts = { ...DEFAULT_HYSTERESIS, ...options };
141
+
142
+ if (candidate.key === null) {
143
+ return { key: null, changed: prev !== null, state: null };
144
+ }
145
+ if (prev === null) {
146
+ return commitTarget(candidate.key, candidate.pointer, now);
147
+ }
148
+ if (keysEqual(candidate.key, prev.key)) {
149
+ return {
150
+ key: prev.key,
151
+ changed: false,
152
+ state: { ...prev, pendingKey: null, pendingAt: 0 },
153
+ };
154
+ }
155
+
156
+ const pendingContinues = keysEqual(candidate.key, prev.pendingKey);
157
+ const pendingKey = candidate.key;
158
+ const pendingAt = pendingContinues ? prev.pendingAt : now;
159
+ const dwelledFor = now - pendingAt;
160
+ const sameContainer = candidate.key.containerKey === prev.key.containerKey;
161
+
162
+ let accept: boolean;
163
+ if (sameContainer) {
164
+ const movedPx = Math.hypot(
165
+ candidate.pointer.x - prev.committedPointer.x,
166
+ candidate.pointer.y - prev.committedPointer.y,
167
+ );
168
+ accept = movedPx >= opts.movePx || dwelledFor >= opts.dwellMs;
169
+ } else if (candidate.isLeave) {
170
+ accept = true;
171
+ } else {
172
+ accept =
173
+ candidate.containerPenetrationPx >= opts.containerPenetrationPx ||
174
+ dwelledFor >= opts.containerDwellMs;
175
+ }
176
+
177
+ if (accept) {
178
+ return commitTarget(candidate.key, candidate.pointer, now);
179
+ }
180
+ return {
181
+ key: prev.key,
182
+ changed: false,
183
+ state: { ...prev, pendingKey, pendingAt },
184
+ };
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // 2. Size guard
189
+ // ---------------------------------------------------------------------------
190
+
191
+ export interface SizeGuardBox {
192
+ width: number;
193
+ height: number;
194
+ }
195
+
196
+ export interface SizeGuardOptions {
197
+ /** Cmd/⌘ held — user override, always allow. Default false. */
198
+ bypass?: boolean;
199
+ /** Slack in px before a container counts as "too small". Default 0. */
200
+ tolerancePx?: number;
201
+ /**
202
+ * Axis on which the container hugs its content (and would therefore grow to
203
+ * fit a larger child, so it should NOT be rejected on that axis). Matches
204
+ * Figma "Hug contents". Default "none".
205
+ */
206
+ hugAxis?: "none" | "main" | "cross" | "both" | "width" | "height";
207
+ }
208
+
209
+ /**
210
+ * Figma's "don't drop a large image into a button" guard: reject a container
211
+ * whose content box is smaller than the dragged element on an axis it cannot
212
+ * grow on. Bypassed by ⌘ (the same modifier that disables snapping).
213
+ */
214
+ export function isContainerTooSmallForDrag(
215
+ containerContentBox: SizeGuardBox,
216
+ draggedRect: SizeGuardBox,
217
+ options: SizeGuardOptions = {},
218
+ ): boolean {
219
+ if (options.bypass) return false;
220
+ const tol = options.tolerancePx ?? 0;
221
+ const hug = options.hugAxis ?? "none";
222
+ const hugsWidth = hug === "both" || hug === "width" || hug === "main";
223
+ const hugsHeight = hug === "both" || hug === "height" || hug === "cross";
224
+
225
+ const tooNarrow =
226
+ !hugsWidth && containerContentBox.width + tol < draggedRect.width;
227
+ const tooShort =
228
+ !hugsHeight && containerContentBox.height + tol < draggedRect.height;
229
+ return tooNarrow || tooShort;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // 3. Packed-container detection (Phase 1, option-1 restriction)
234
+ // ---------------------------------------------------------------------------
235
+
236
+ export interface PackedContainerInfo {
237
+ /** Computed `display`. */
238
+ display: string;
239
+ /** Computed `flex-direction`. */
240
+ flexDirection: string;
241
+ /** Computed `flex-wrap`. */
242
+ flexWrap: string;
243
+ /** Computed `justify-content`. */
244
+ justifyContent: string;
245
+ /** Resolved main-axis gap in px (row-gap or column-gap as appropriate). */
246
+ gap: number;
247
+ /** True if any direct child has `flex-grow` > 0 (it absorbs space instead
248
+ * of translating, so a constant slot shift would misrepresent the drop). */
249
+ hasFlexGrowChild: boolean;
250
+ }
251
+
252
+ const START_JUSTIFY = new Set(["flex-start", "start", "normal", "left", ""]);
253
+
254
+ /**
255
+ * Whether a container is simple enough that a same-magnitude per-sibling
256
+ * translate exactly reproduces the post-drop layout (see
257
+ * `computeReorderOffsets`). Only START-aligned, non-wrapping, fixed-gap,
258
+ * non-reverse flex rows/columns with no growing child qualify.
259
+ *
260
+ * Everything else (space-between/around/evenly, center/end, wrap, grid,
261
+ * reverse, or a flex-grow child) must fall back to an indicator-only preview,
262
+ * because the real reflow is NOT a uniform shift and animating a constant
263
+ * translate would show a preview that does not match where the item lands.
264
+ */
265
+ export function isSimplePackedContainer(info: PackedContainerInfo): boolean {
266
+ const isFlex = info.display === "flex" || info.display === "inline-flex";
267
+ if (!isFlex) return false;
268
+ if (info.flexDirection !== "row" && info.flexDirection !== "column") {
269
+ return false;
270
+ }
271
+ if (info.flexWrap !== "nowrap") return false;
272
+ if (!START_JUSTIFY.has(info.justifyContent)) return false;
273
+ if (!Number.isFinite(info.gap) || info.gap < 0) return false;
274
+ if (info.hasFlexGrowChild) return false;
275
+ return true;
276
+ }
277
+
278
+ /** The main flow axis (x/y) for a packed container's flex-direction. */
279
+ export function mainAxisForDirection(flexDirection: string): "x" | "y" {
280
+ return flexDirection === "column" || flexDirection === "column-reverse"
281
+ ? "y"
282
+ : "x";
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // 4. Reflow offsets (packed case)
287
+ // ---------------------------------------------------------------------------
288
+
289
+ export interface ReorderOffsetsInput {
290
+ /** Number of direct children currently in the container (incl. dragged). */
291
+ count: number;
292
+ /** The dragged element's current index. */
293
+ originIndex: number;
294
+ /**
295
+ * Insertion slot in current indexing: the item lands *before* the child at
296
+ * `targetSlot` (0..count, where `count` means "at the end").
297
+ */
298
+ targetSlot: number;
299
+ /** Dragged element's main-axis size + the container gap, in px. */
300
+ slotMain: number;
301
+ }
302
+
303
+ /**
304
+ * Per-sibling translate offsets (px, signed along the main axis) that preview
305
+ * a same-container reorder by opening the destination slot and closing the
306
+ * origin slot. Negative = toward the container start (up/left); positive =
307
+ * toward the end (down/right). The dragged element's own index is always 0
308
+ * here (it follows the cursor separately).
309
+ *
310
+ * For a packed container, moving the dragged item (size s, plus one gap g)
311
+ * from `originIndex` to `targetSlot` shifts exactly the siblings between the
312
+ * two positions by ±(s + g) — the shift magnitude is independent of each
313
+ * sibling's own size, which is why the constant model is exact for this case.
314
+ */
315
+ export function computeReorderOffsets(input: ReorderOffsetsInput): number[] {
316
+ const { count, originIndex, targetSlot, slotMain } = input;
317
+ const offsets = new Array(count).fill(0);
318
+ if (targetSlot > originIndex + 1) {
319
+ // Moving later: siblings between move toward the start to fill the origin.
320
+ for (let i = originIndex + 1; i <= targetSlot - 1; i += 1) {
321
+ offsets[i] = -slotMain;
322
+ }
323
+ } else if (targetSlot < originIndex) {
324
+ // Moving earlier: siblings from targetSlot..originIndex-1 move toward end.
325
+ for (let i = targetSlot; i <= originIndex - 1; i += 1) {
326
+ offsets[i] = slotMain;
327
+ }
328
+ }
329
+ // targetSlot === originIndex or originIndex + 1 → no net movement.
330
+ return offsets;
331
+ }
332
+
333
+ export interface VacateOffsetsInput {
334
+ count: number;
335
+ originIndex: number;
336
+ slotMain: number;
337
+ }
338
+
339
+ /**
340
+ * Offsets for the ORIGIN container when the dragged item leaves it entirely
341
+ * (drag-out or cross-container move): every following sibling closes the gap
342
+ * by shifting toward the start. This is what erases the "hole" the old
343
+ * relative-promotion approach left behind.
344
+ */
345
+ export function computeVacateOffsets(input: VacateOffsetsInput): number[] {
346
+ const { count, originIndex, slotMain } = input;
347
+ const offsets = new Array(count).fill(0);
348
+ for (let i = originIndex + 1; i <= count - 1; i += 1) {
349
+ offsets[i] = -slotMain;
350
+ }
351
+ return offsets;
352
+ }
353
+
354
+ export interface InsertOffsetsInput {
355
+ /** Number of children currently in the TARGET container (excl. incoming). */
356
+ count: number;
357
+ /** Insertion slot: incoming item lands before child at `targetSlot`. */
358
+ targetSlot: number;
359
+ /** Incoming element's main-axis size + the target container's gap, in px. */
360
+ slotMain: number;
361
+ }
362
+
363
+ /**
364
+ * Offsets for a TARGET container the dragged item is entering: children at and
365
+ * after the insertion slot shift toward the end to open a slot sized to the
366
+ * incoming element.
367
+ */
368
+ export function computeInsertOffsets(input: InsertOffsetsInput): number[] {
369
+ const { count, targetSlot, slotMain } = input;
370
+ const offsets = new Array(count).fill(0);
371
+ for (let i = targetSlot; i <= count - 1; i += 1) {
372
+ offsets[i] = slotMain;
373
+ }
374
+ return offsets;
375
+ }
@@ -13,7 +13,7 @@
13
13
  * a design as app-backed.
14
14
  */
15
15
 
16
- import { defineFeatureFlag } from "@agent-native/core/feature-flags";
16
+ import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry";
17
17
 
18
18
  /**
19
19
  * Runtime rollout for full app building in the Design app. Builder