@agent-native/core 0.148.0 → 0.149.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 (165) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/changelog/2026-08-10-wide-tables-in-analytics-chat-now-scroll-horizontally-within.md +6 -0
  3. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +13 -53
  4. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +11 -70
  5. package/corpus/templates/calendar/app/lib/event-layout.ts +122 -0
  6. package/corpus/templates/calendar/app/routes/_app.availability.tsx +1 -1
  7. package/corpus/templates/calendar/changelog/2026-08-10-opening-availability-settings-from-the-agent-lands-on-the-av.md +6 -0
  8. package/corpus/templates/calendar/vitest.config.ts +6 -1
  9. package/corpus/templates/clips/actions/lib/audio-only-transcription.ts +3 -1
  10. package/corpus/templates/clips/app/components/player/video-player.tsx +44 -4
  11. package/corpus/templates/clips/changelog/2026-08-10-fixed-transcription-failures-for-recordings-without-captured.md +6 -0
  12. package/corpus/templates/clips/changelog/2026-08-10-mobile-clips-can-now-enter-fullscreen-on-iphone-safari.md +6 -0
  13. package/corpus/templates/content/actions/_property-utils.ts +11 -1
  14. package/corpus/templates/content/actions/view-screen.ts +62 -12
  15. package/corpus/templates/content/server/plugins/agent-chat.ts +2 -0
  16. package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +22 -0
  17. package/corpus/templates/design/AGENTS.md +3 -0
  18. package/corpus/templates/design/actions/create-design-from-template.ts +19 -0
  19. package/corpus/templates/design/actions/get-design-snapshot.ts +18 -0
  20. package/corpus/templates/design/actions/get-design-template.ts +178 -0
  21. package/corpus/templates/design/actions/list-designs.ts +3 -1
  22. package/corpus/templates/design/actions/view-screen.ts +31 -0
  23. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +225 -214
  24. package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -0
  25. package/corpus/templates/design/app/i18n-data.ts +78 -0
  26. package/corpus/templates/design/app/lib/design-authors.ts +64 -0
  27. package/corpus/templates/design/app/pages/Index.tsx +114 -4
  28. package/corpus/templates/design/app/pages/design-editor/generation-prompt-directives.ts +1 -0
  29. package/corpus/templates/design/changelog/2026-08-07-alt-dragging-an-element-to-copy-it-now-keeps-the-copy-visibl.md +6 -0
  30. package/corpus/templates/design/changelog/2026-08-07-design-cards-now-show-who-created-each-design-with-a-filter-.md +6 -0
  31. package/corpus/templates/design/changelog/2026-08-07-drawing-a-shape-on-the-overview-canvas-now-keeps-the-shape-s.md +6 -0
  32. package/corpus/templates/design/changelog/2026-08-07-hold-option-alt-while-dragging-a-frame-on-the-overview-canva.md +6 -0
  33. package/corpus/templates/design/server/lib/design-template-data.ts +143 -0
  34. package/corpus/templates/design/server/plugins/agent-chat.ts +1 -0
  35. package/corpus/templates/forms/AGENTS.md +2 -2
  36. package/corpus/templates/forms/actions/create-form.ts +1 -1
  37. package/corpus/templates/forms/actions/update-form.ts +5 -5
  38. package/corpus/templates/forms/changelog/2026-08-10-forms-now-keep-agent-applied-field-changes-visible-after-an-.md +6 -0
  39. package/corpus/templates/forms/changelog/2026-08-10-published-form-links-always-use-the-working-public-form-url.md +6 -0
  40. package/corpus/templates/forms/server/plugins/agent-chat.ts +2 -2
  41. package/corpus/templates/slides/AGENTS.md +4 -3
  42. package/corpus/templates/slides/actions/import-google-slides-reference.ts +56 -10
  43. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +12 -74
  44. package/corpus/templates/slides/app/components/editor/GeneratingSlidePreview.tsx +5 -1
  45. package/corpus/templates/slides/app/components/editor/GoogleDriveConnectionCta.tsx +15 -4
  46. package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +116 -55
  47. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +1 -0
  48. package/corpus/templates/slides/app/components/onboarding/FirstDeckOnboardingFlow.tsx +143 -18
  49. package/corpus/templates/slides/app/i18n/en-US.ts +6 -0
  50. package/corpus/templates/slides/app/lib/generation-state.ts +7 -1
  51. package/corpus/templates/slides/app/pages/DeckEditor.tsx +32 -14
  52. package/corpus/templates/slides/app/pages/Index.tsx +110 -128
  53. package/corpus/templates/slides/changelog/2026-08-10-imported-reference-decks-now-show-clear-success-and-stay-sel.md +6 -0
  54. package/corpus/templates/slides/changelog/2026-08-10-pasted-google-slides-links-import-after-reconnecting-google-.md +6 -0
  55. package/corpus/templates/slides/server/handlers/google-docs-auth.ts +4 -0
  56. package/corpus/templates/slides/server/lib/google-docs-oauth.ts +13 -0
  57. package/corpus/templates/slides/server/plugins/agent-chat.ts +1 -1
  58. package/dist/agent/engine/registry.d.ts +8 -2
  59. package/dist/agent/engine/registry.js +63 -29
  60. package/dist/agent/production-agent.js +7 -0
  61. package/dist/agent/run-manager.d.ts +5 -4
  62. package/dist/agent/run-manager.js +6 -5
  63. package/dist/cli/claude-code-participant.d.ts +2 -1
  64. package/dist/cli/claude-code-participant.js +19 -0
  65. package/dist/cli/code-agent-executor.js +223 -0
  66. package/dist/cli/create-workspace.js +3 -3
  67. package/dist/cli/create.d.ts +3 -2
  68. package/dist/cli/create.js +20 -24
  69. package/dist/client/AgentPanel.js +8 -4
  70. package/dist/client/AssistantChat.js +34 -1
  71. package/dist/client/agent-chat-adapter.d.ts +1 -0
  72. package/dist/client/agent-chat-adapter.js +10 -2
  73. package/dist/client/blocks/library/diagram.js +1 -1
  74. package/dist/client/blocks/library/sanitize-html.js +66 -2
  75. package/dist/client/blocks/library/wireframe-kit.js +69 -0
  76. package/dist/client/chat/markdown-renderer.d.ts +1 -0
  77. package/dist/client/chat/markdown-renderer.js +4 -0
  78. package/dist/client/chat/message-components.d.ts +1 -0
  79. package/dist/client/chat/message-components.js +9 -1
  80. package/dist/client/chat/tool-call-display.d.ts +8 -0
  81. package/dist/client/chat/tool-call-display.js +30 -21
  82. package/dist/client/chat-first/app-pane.js +1 -1
  83. package/dist/client/chat-first/apps-rail.d.ts +1 -1
  84. package/dist/client/chat-first/apps-rail.js +12 -12
  85. package/dist/client/chat-first/chat-history.d.ts +1 -1
  86. package/dist/client/chat-first/chat-history.js +2 -0
  87. package/dist/client/chat-first/copy.js +1 -0
  88. package/dist/client/chat-first/primary-nav.js +1 -1
  89. package/dist/client/chat-first/types.d.ts +1 -0
  90. package/dist/client/resources/McpConnectionSuggestion.js +2 -2
  91. package/dist/client/settings/SettingsTabsPage.d.ts +3 -1
  92. package/dist/client/settings/SettingsTabsPage.js +2 -2
  93. package/dist/client/sse-event-processor.d.ts +8 -0
  94. package/dist/client/sse-event-processor.js +13 -6
  95. package/dist/collab/struct-routes.d.ts +1 -1
  96. package/dist/deploy/build.d.ts +5 -7
  97. package/dist/deploy/build.js +19 -12
  98. package/dist/localization/default-messages.js +1 -1
  99. package/dist/observability/routes.d.ts +3 -3
  100. package/dist/provider-api/actions/provider-api.d.ts +2 -2
  101. package/dist/scripts/call-agent.js +3 -3
  102. package/dist/scripts/describe-workspace-apps.js +1 -1
  103. package/dist/secrets/routes.d.ts +6 -6
  104. package/dist/server/agent-capabilities.js +0 -0
  105. package/dist/server/agent-chat/context-tools.js +1 -1
  106. package/dist/server/agent-chat/prompt-resources.js +1 -1
  107. package/dist/server/agent-chat-plugin.js +1 -1
  108. package/dist/server/auth.js +190 -42
  109. package/dist/server/better-auth-migrations.js +53 -0
  110. package/dist/server/http-response-telemetry.js +7 -1
  111. package/dist/server/onboarding-html.js +119 -33
  112. package/dist/server/transcribe-voice.d.ts +1 -1
  113. package/dist/shared/password-policy.d.ts +2 -1
  114. package/dist/shared/password-policy.js +2 -1
  115. package/dist/styles/agent-conversation.css +20 -3
  116. package/dist/styles/agent-native.css +14 -2
  117. package/dist/styles/blocks.css +132 -0
  118. package/dist/templates/default/app/i18n/ar-SA.ts +1 -1
  119. package/dist/templates/default/app/i18n/de-DE.ts +1 -1
  120. package/dist/templates/default/app/i18n/en-US.ts +1 -1
  121. package/dist/templates/default/app/i18n/es-ES.ts +1 -1
  122. package/dist/templates/default/app/i18n/fr-FR.ts +1 -1
  123. package/dist/templates/default/app/i18n/hi-IN.ts +1 -1
  124. package/dist/templates/default/app/i18n/ja-JP.ts +1 -1
  125. package/dist/templates/default/app/i18n/ko-KR.ts +1 -1
  126. package/dist/templates/default/app/i18n/pt-BR.ts +1 -1
  127. package/dist/templates/default/app/i18n/zh-CN.ts +1 -1
  128. package/dist/templates/default/app/i18n/zh-TW.ts +1 -1
  129. package/dist/templates/default/pnpm-workspace.yaml +1 -0
  130. package/dist/templates/workspace-root/pnpm-workspace.yaml +2 -0
  131. package/docs/content/actions.mdx +4 -1
  132. package/docs/content/agent-surfaces.mdx +8 -6
  133. package/docs/content/automations.mdx +1 -1
  134. package/docs/content/cloneable-saas.mdx +6 -6
  135. package/docs/content/database.mdx +6 -1
  136. package/docs/content/deployment.mdx +10 -8
  137. package/docs/content/docs-components.mdx +22 -14
  138. package/docs/content/drop-in-agent.mdx +1 -1
  139. package/docs/content/environment-variables.mdx +4 -3
  140. package/docs/content/getting-started-actions.mdx +5 -4
  141. package/docs/content/getting-started-pages.mdx +7 -6
  142. package/docs/content/getting-started.mdx +4 -3
  143. package/docs/content/notifications.mdx +6 -6
  144. package/docs/content/progress.mdx +5 -5
  145. package/docs/content/routing.mdx +3 -1
  146. package/docs/content/server-database.mdx +13 -4
  147. package/docs/content/server-overview.mdx +9 -9
  148. package/docs/content/server-plugins.mdx +10 -4
  149. package/docs/content/server-routes.mdx +7 -2
  150. package/docs/content/tracking.mdx +4 -4
  151. package/docs/content/what-is-agent-native.mdx +4 -4
  152. package/package.json +2 -2
  153. package/src/templates/default/app/i18n/ar-SA.ts +1 -1
  154. package/src/templates/default/app/i18n/de-DE.ts +1 -1
  155. package/src/templates/default/app/i18n/en-US.ts +1 -1
  156. package/src/templates/default/app/i18n/es-ES.ts +1 -1
  157. package/src/templates/default/app/i18n/fr-FR.ts +1 -1
  158. package/src/templates/default/app/i18n/hi-IN.ts +1 -1
  159. package/src/templates/default/app/i18n/ja-JP.ts +1 -1
  160. package/src/templates/default/app/i18n/ko-KR.ts +1 -1
  161. package/src/templates/default/app/i18n/pt-BR.ts +1 -1
  162. package/src/templates/default/app/i18n/zh-CN.ts +1 -1
  163. package/src/templates/default/app/i18n/zh-TW.ts +1 -1
  164. package/src/templates/default/pnpm-workspace.yaml +1 -0
  165. package/src/templates/workspace-root/pnpm-workspace.yaml +2 -0
@@ -5074,10 +5074,210 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5074
5074
  [updateSelectedDraftIds, updateSelectedIds],
5075
5075
  );
5076
5076
 
5077
+ const beginDuplicateGesture = useCallback(
5078
+ (id: string, e: React.MouseEvent) => {
5079
+ const entries = getCurrentFrameEntries();
5080
+ const selection = selectedIdsRef.current;
5081
+ const targets = (selection.includes(id) ? selection : [id])
5082
+ .filter((targetId) => !lockedScreenIdSet.has(targetId))
5083
+ .flatMap((targetId) => {
5084
+ const target = screensRef.current.find((s) => s.id === targetId);
5085
+ const geometry = entries.find(
5086
+ (entry) => entry.id === targetId,
5087
+ )?.geometry;
5088
+ return target && geometry ? [{ screen: target, geometry }] : [];
5089
+ });
5090
+ const source = targets.find((target) => target.screen.id === id);
5091
+ if (!source) return;
5092
+ e.preventDefault();
5093
+ e.stopPropagation();
5094
+ duplicateCleanup.current?.();
5095
+
5096
+ const display = screenDisplayName(
5097
+ source.screen,
5098
+ getResolvedMetadata(source.screen),
5099
+ );
5100
+ const surfaceRect = surfaceRef.current?.getBoundingClientRect();
5101
+ const origin = { x: e.clientX, y: e.clientY };
5102
+ const originCanvas = canvasPointFromClient(e.clientX, e.clientY);
5103
+ const previewPoint = {
5104
+ x: surfaceRect ? e.clientX - surfaceRect.left + 16 : e.clientX,
5105
+ y: surfaceRect ? e.clientY - surfaceRect.top + 16 : e.clientY,
5106
+ };
5107
+
5108
+ const previewWidth = source.geometry.width;
5109
+ const previewHeight = source.geometry.height;
5110
+
5111
+ setDuplicatePreview({
5112
+ display,
5113
+ count: targets.length,
5114
+ x: previewPoint.x,
5115
+ y: previewPoint.y,
5116
+ width: previewWidth,
5117
+ height: previewHeight,
5118
+ canDuplicate: !!onDuplicate,
5119
+ moved: false,
5120
+ });
5121
+ // Mount the interaction shield and mute preview-iframe pointer events for
5122
+ // the duration of the gesture, same as every other drag — otherwise the
5123
+ // pointer freezes crossing a live embedded iframe and a release over a
5124
+ // screen never reaches handleMouseUp.
5125
+ setIsDragging(true);
5126
+ // Figma parity: show a copy-affordance cursor while the alt-drag
5127
+ // duplicate gesture is armed. Tracked live in handleMouseMove below
5128
+ // (same live e.altKey tracking that already drives canDuplicate), so
5129
+ // releasing alt mid-drag falls back to the default arrow instead of
5130
+ // staying on the copy cursor for a gesture that will no longer
5131
+ // duplicate on mouseup.
5132
+ setDragCursor(e.altKey ? "copy" : null);
5133
+
5134
+ // PERF: track the last committed canDuplicate/moved/cursor values in
5135
+ // plain closure locals (not state) so the mousemove tick below can tell
5136
+ // whether anything conditional actually changed without reading back
5137
+ // through React state (which the imperative writes below intentionally
5138
+ // stop keeping fresh every tick — see duplicatePreviewElRef).
5139
+ let lastCanDuplicate = !!onDuplicate;
5140
+ let lastMoved = false;
5141
+ let lastCursor: string | null = e.altKey ? "copy" : null;
5142
+
5143
+ const handleMouseMove = (ev: MouseEvent) => {
5144
+ const dx = ev.clientX - origin.x;
5145
+ const dy = ev.clientY - origin.y;
5146
+ const moved = Math.hypot(dx, dy) >= DUPLICATE_DRAG_THRESHOLD;
5147
+ const rect = surfaceRef.current?.getBoundingClientRect();
5148
+ const x = rect ? ev.clientX - rect.left + 16 : ev.clientX;
5149
+ const y = rect ? ev.clientY - rect.top + 16 : ev.clientY;
5150
+ // Live alt state, not just capability: if the user releases alt
5151
+ // mid-drag the preview should visibly fall back to its "not armed"
5152
+ // dashed/preview styling, matching that mouseup will then cancel
5153
+ // the duplicate instead of creating one (see handleMouseUp below).
5154
+ const canDuplicate = !!onDuplicate && ev.altKey;
5155
+
5156
+ // PERF9: the ghost's position is a direct DOM write every tick
5157
+ // instead of a setDuplicatePreview (full re-render) — same
5158
+ // "imperative now, commit state only when something conditional
5159
+ // changes" discipline as the frame/draft drag paths above. Falls
5160
+ // back to setDuplicatePreview itself if the node hasn't mounted yet
5161
+ // (e.g. the very first tick right after mousedown, before React has
5162
+ // committed the initial preview).
5163
+ const el = duplicatePreviewElRef.current;
5164
+ if (el) {
5165
+ el.style.left = `${x}px`;
5166
+ el.style.top = `${y}px`;
5167
+ }
5168
+
5169
+ if (!el || canDuplicate !== lastCanDuplicate || moved !== lastMoved) {
5170
+ lastCanDuplicate = canDuplicate;
5171
+ lastMoved = moved;
5172
+ setDuplicatePreview({
5173
+ display,
5174
+ count: targets.length,
5175
+ x,
5176
+ y,
5177
+ width: previewWidth,
5178
+ height: previewHeight,
5179
+ canDuplicate,
5180
+ moved,
5181
+ });
5182
+ }
5183
+
5184
+ const cursor = ev.altKey ? "copy" : null;
5185
+ if (cursor !== lastCursor) {
5186
+ lastCursor = cursor;
5187
+ setDragCursor(cursor);
5188
+ }
5189
+ };
5190
+
5191
+ const cleanupDuplicateGesture = () => {
5192
+ setDuplicatePreview(null);
5193
+ duplicateCleanup.current = null;
5194
+ // finishDrag clears isDragging, unmounts the shield, and — critically —
5195
+ // runs dragCleanup.current() to detach the window listeners installed
5196
+ // by installDragListeners and restore preview-iframe pointer events.
5197
+ // dragState.current was never set for this gesture, so finishDrag's
5198
+ // other resets (marquee/creation-preview/etc.) are no-ops here.
5199
+ finishDrag();
5200
+ };
5201
+
5202
+ const handleMouseUp = (ev: MouseEvent) => {
5203
+ const moved =
5204
+ Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) >=
5205
+ DUPLICATE_DRAG_THRESHOLD;
5206
+ // Alt is read live, not from mousedown: the gesture only ever showed a
5207
+ // ghost, so releasing alt must cancel outright, never fall back to a
5208
+ // move the original frame never started.
5209
+ const shouldDuplicate = moved && ev.altKey;
5210
+
5211
+ if (onDuplicate && shouldDuplicate) {
5212
+ const dropCanvasPosition = canvasPointFromClient(
5213
+ ev.clientX,
5214
+ ev.clientY,
5215
+ );
5216
+ const delta = {
5217
+ x: dropCanvasPosition.x - originCanvas.x,
5218
+ y: dropCanvasPosition.y - originCanvas.y,
5219
+ };
5220
+ // The user placed these clones deliberately — suppress the
5221
+ // lineup-recenter camera move when the created screens land in
5222
+ // `screens` (Figma keeps the camera still on alt-drag duplicate).
5223
+ lineupRecenterSuppressRef.current = {
5224
+ atMs: Date.now(),
5225
+ fromCount: screensRef.current.length,
5226
+ addedCount: targets.length,
5227
+ };
5228
+ targets.forEach((target) => {
5229
+ const canvasPosition = {
5230
+ x: target.geometry.x + delta.x,
5231
+ y: target.geometry.y + delta.y,
5232
+ };
5233
+ onDuplicate(target.screen.id, {
5234
+ mode: "alt-drag",
5235
+ screen: target.screen,
5236
+ canvasPosition,
5237
+ canvasOffset: {
5238
+ x: dropCanvasPosition.x - canvasPosition.x,
5239
+ y: dropCanvasPosition.y - canvasPosition.y,
5240
+ },
5241
+ dropCanvasPosition,
5242
+ });
5243
+ });
5244
+ } else if (!moved) {
5245
+ onPick(id);
5246
+ }
5247
+
5248
+ cleanupDuplicateGesture();
5249
+ };
5250
+
5251
+ duplicateCleanup.current = cleanupDuplicateGesture;
5252
+ installDragListeners(
5253
+ handleMouseMove,
5254
+ handleMouseUp,
5255
+ cleanupDuplicateGesture,
5256
+ );
5257
+ },
5258
+ [
5259
+ canvasPointFromClient,
5260
+ finishDrag,
5261
+ getCurrentFrameEntries,
5262
+ getResolvedMetadata,
5263
+ installDragListeners,
5264
+ lockedScreenIdSet,
5265
+ onDuplicate,
5266
+ onPick,
5267
+ ],
5268
+ );
5269
+
5077
5270
  const beginFrameDrag = useCallback(
5078
5271
  (id: string, e: React.MouseEvent) => {
5079
5272
  if (readOnly) return;
5080
- if (e.button !== 0 || e.shiftKey || lockedScreenIdSet.has(id)) return;
5273
+ if (e.button !== 0 || lockedScreenIdSet.has(id)) return;
5274
+ // Frame body, label row, and the selection box over a selected frame all
5275
+ // start drags here, so alt's move-vs-copy meaning is decided exactly once.
5276
+ if (e.altKey) {
5277
+ beginDuplicateGesture(id, e);
5278
+ return;
5279
+ }
5280
+ if (e.shiftKey) return;
5081
5281
  e.preventDefault();
5082
5282
  e.stopPropagation();
5083
5283
  // Frame mousedowns stop propagation, so they never reach handleMouseDown.
@@ -5429,6 +5629,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5429
5629
  },
5430
5630
  [
5431
5631
  activeId,
5632
+ beginDuplicateGesture,
5432
5633
  findPrimitiveDropTarget,
5433
5634
  finishDrag,
5434
5635
  getCanvasPoint,
@@ -6284,191 +6485,6 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6284
6485
  ],
6285
6486
  );
6286
6487
 
6287
- const beginDuplicateGesture = useCallback(
6288
- (screen: ScreenFile, display: string, e: React.MouseEvent<HTMLElement>) => {
6289
- if (readOnly) return;
6290
- if (e.button !== 0 || !e.altKey || lockedScreenIdSet.has(screen.id)) {
6291
- return;
6292
- }
6293
- e.preventDefault();
6294
- e.stopPropagation();
6295
- duplicateCleanup.current?.();
6296
-
6297
- const surfaceRect = surfaceRef.current?.getBoundingClientRect();
6298
- const origin = { x: e.clientX, y: e.clientY };
6299
- const originCanvas = canvasPointFromClient(e.clientX, e.clientY);
6300
- const sourceFrame = getCurrentFrameEntries().find(
6301
- (entry) => entry.id === screen.id,
6302
- );
6303
- const pointerOffset = sourceFrame
6304
- ? {
6305
- x: originCanvas.x - sourceFrame.geometry.x,
6306
- y: originCanvas.y - sourceFrame.geometry.y,
6307
- }
6308
- : { x: 0, y: 0 };
6309
- const previewPoint = {
6310
- x: surfaceRect ? e.clientX - surfaceRect.left + 16 : e.clientX,
6311
- y: surfaceRect ? e.clientY - surfaceRect.top + 16 : e.clientY,
6312
- };
6313
-
6314
- const previewWidth = sourceFrame?.geometry.width ?? SCREEN_WIDTH;
6315
- const previewHeight = sourceFrame?.geometry.height ?? SCREEN_HEIGHT;
6316
-
6317
- setDuplicatePreview({
6318
- display,
6319
- x: previewPoint.x,
6320
- y: previewPoint.y,
6321
- width: previewWidth,
6322
- height: previewHeight,
6323
- canDuplicate: !!onDuplicate,
6324
- moved: false,
6325
- });
6326
- // Mount the interaction shield and mute preview-iframe pointer events for
6327
- // the duration of the gesture, same as every other drag — otherwise the
6328
- // pointer freezes crossing a live embedded iframe and a release over a
6329
- // screen never reaches handleMouseUp.
6330
- setIsDragging(true);
6331
- // Figma parity: show a copy-affordance cursor while the alt-drag
6332
- // duplicate gesture is armed. Tracked live in handleMouseMove below
6333
- // (same live e.altKey tracking that already drives canDuplicate), so
6334
- // releasing alt mid-drag falls back to the default arrow instead of
6335
- // staying on the copy cursor for a gesture that will no longer
6336
- // duplicate on mouseup.
6337
- setDragCursor(e.altKey ? "copy" : null);
6338
-
6339
- // PERF: track the last committed canDuplicate/moved/cursor values in
6340
- // plain closure locals (not state) so the mousemove tick below can tell
6341
- // whether anything conditional actually changed without reading back
6342
- // through React state (which the imperative writes below intentionally
6343
- // stop keeping fresh every tick — see duplicatePreviewElRef).
6344
- let lastCanDuplicate = !!onDuplicate;
6345
- let lastMoved = false;
6346
- let lastCursor: string | null = e.altKey ? "copy" : null;
6347
-
6348
- const handleMouseMove = (ev: MouseEvent) => {
6349
- const dx = ev.clientX - origin.x;
6350
- const dy = ev.clientY - origin.y;
6351
- const moved = Math.hypot(dx, dy) >= DUPLICATE_DRAG_THRESHOLD;
6352
- const rect = surfaceRef.current?.getBoundingClientRect();
6353
- const x = rect ? ev.clientX - rect.left + 16 : ev.clientX;
6354
- const y = rect ? ev.clientY - rect.top + 16 : ev.clientY;
6355
- // Live alt state, not just capability: if the user releases alt
6356
- // mid-drag the preview should visibly fall back to its "not armed"
6357
- // dashed/preview styling, matching that mouseup will then cancel
6358
- // the duplicate instead of creating one (see handleMouseUp below).
6359
- const canDuplicate = !!onDuplicate && ev.altKey;
6360
-
6361
- // PERF9: the ghost's position is a direct DOM write every tick
6362
- // instead of a setDuplicatePreview (full re-render) — same
6363
- // "imperative now, commit state only when something conditional
6364
- // changes" discipline as the frame/draft drag paths above. Falls
6365
- // back to setDuplicatePreview itself if the node hasn't mounted yet
6366
- // (e.g. the very first tick right after mousedown, before React has
6367
- // committed the initial preview).
6368
- const el = duplicatePreviewElRef.current;
6369
- if (el) {
6370
- el.style.left = `${x}px`;
6371
- el.style.top = `${y}px`;
6372
- }
6373
-
6374
- if (!el || canDuplicate !== lastCanDuplicate || moved !== lastMoved) {
6375
- lastCanDuplicate = canDuplicate;
6376
- lastMoved = moved;
6377
- setDuplicatePreview({
6378
- display,
6379
- x,
6380
- y,
6381
- width: previewWidth,
6382
- height: previewHeight,
6383
- canDuplicate,
6384
- moved,
6385
- });
6386
- }
6387
-
6388
- const cursor = ev.altKey ? "copy" : null;
6389
- if (cursor !== lastCursor) {
6390
- lastCursor = cursor;
6391
- setDragCursor(cursor);
6392
- }
6393
- };
6394
-
6395
- const cleanupDuplicateGesture = () => {
6396
- setDuplicatePreview(null);
6397
- duplicateCleanup.current = null;
6398
- // finishDrag clears isDragging, unmounts the shield, and — critically —
6399
- // runs dragCleanup.current() to detach the window listeners installed
6400
- // by installDragListeners and restore preview-iframe pointer events.
6401
- // dragState.current was never set for this gesture, so finishDrag's
6402
- // other resets (marquee/creation-preview/etc.) are no-ops here.
6403
- finishDrag();
6404
- };
6405
-
6406
- const handleMouseUp = (ev: MouseEvent) => {
6407
- const moved =
6408
- Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) >=
6409
- DUPLICATE_DRAG_THRESHOLD;
6410
- const mode = moved ? "alt-drag" : "alt-click";
6411
- // Figma semantics: a plain alt-click (no drag) never duplicates —
6412
- // only an actual alt-drag does. And alt is evaluated live: releasing
6413
- // it before mouseup cancels the pending duplicate rather than
6414
- // creating one anyway (this gesture never moves the original frame
6415
- // during the drag — it only shows a floating ghost preview — so
6416
- // "cancel" here means no-op, not handing off to a live move).
6417
- const shouldDuplicate = moved && ev.altKey;
6418
-
6419
- if (onDuplicate && shouldDuplicate) {
6420
- const dropCanvasPosition = canvasPointFromClient(
6421
- ev.clientX,
6422
- ev.clientY,
6423
- );
6424
- // shouldDuplicate implies moved, so the drop position is always
6425
- // relative to the pointer's offset into the source frame (the
6426
- // "snap next to source" placement only applied to the old
6427
- // zero-move alt-click case, which no longer duplicates at all).
6428
- const canvasPosition = {
6429
- x: dropCanvasPosition.x - pointerOffset.x,
6430
- y: dropCanvasPosition.y - pointerOffset.y,
6431
- };
6432
- // The user placed this clone deliberately — suppress the
6433
- // lineup-recenter camera move when the created screen lands in
6434
- // `screens` (Figma keeps the camera still on alt-drag duplicate).
6435
- lineupRecenterSuppressRef.current = {
6436
- atMs: Date.now(),
6437
- fromCount: screensRef.current.length,
6438
- addedCount: 1,
6439
- };
6440
- onDuplicate(screen.id, {
6441
- mode,
6442
- screen,
6443
- canvasPosition,
6444
- canvasOffset: pointerOffset,
6445
- dropCanvasPosition,
6446
- });
6447
- } else if (!moved) {
6448
- onPick(screen.id);
6449
- }
6450
-
6451
- cleanupDuplicateGesture();
6452
- };
6453
-
6454
- duplicateCleanup.current = cleanupDuplicateGesture;
6455
- installDragListeners(
6456
- handleMouseMove,
6457
- handleMouseUp,
6458
- cleanupDuplicateGesture,
6459
- );
6460
- },
6461
- [
6462
- canvasPointFromClient,
6463
- finishDrag,
6464
- getCurrentFrameEntries,
6465
- installDragListeners,
6466
- lockedScreenIdSet,
6467
- onDuplicate,
6468
- onPick,
6469
- ],
6470
- );
6471
-
6472
6488
  const handleMouseDown = useCallback(
6473
6489
  (e: React.MouseEvent) => {
6474
6490
  claimKeyboardFocus();
@@ -7386,15 +7402,15 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
7386
7402
  ) {
7387
7403
  return;
7388
7404
  }
7389
- // Always suppress the browser default (bookmark dialog) — but leave
7390
- // propagation intact until we know a frame is duplicable here, so the
7391
- // global hotkey hook can still duplicate non-frame layer selections.
7392
- event.preventDefault();
7393
7405
  // Only act on frame IDs — filter out canvas primitives (sub-elements).
7394
7406
  const frameIds = selectedIdsRef.current.filter(
7395
7407
  (id) => frameGeometryRef.current[id],
7396
7408
  );
7409
+ // Claim the event only once a frame will really be duplicated — the
7410
+ // global hotkey hook skips anything already defaultPrevented, and it
7411
+ // suppresses the bookmark dialog itself when it takes over.
7397
7412
  if (frameIds.length === 0) return;
7413
+ event.preventDefault();
7398
7414
  event.stopPropagation();
7399
7415
  event.stopImmediatePropagation();
7400
7416
  // Duplicate every selected frame, not just the first — each duplicate
@@ -8152,7 +8168,6 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
8152
8168
  onStartFrameDrag={beginFrameDrag}
8153
8169
  onStartResize={beginResize}
8154
8170
  onStartRotate={beginRotate}
8155
- onStartDuplicateGesture={beginDuplicateGesture}
8156
8171
  // Pass the id-first callbacks straight through (PF18): Screen
8157
8172
  // itself binds screen.id when it calls these, so every screen
8158
8173
  // instance gets the exact same stable function reference here
@@ -8574,7 +8589,9 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
8574
8589
  >
8575
8590
  <div className="flex h-full w-full items-start justify-between rounded-lg bg-muted/20 p-2">
8576
8591
  <span className="max-w-[190px] truncate !text-[11px] font-medium text-foreground">
8577
- {duplicatePreview.display}
8592
+ {duplicatePreview.count > 1
8593
+ ? `${duplicatePreview.display} +${duplicatePreview.count - 1}`
8594
+ : duplicatePreview.display}
8578
8595
  </span>
8579
8596
  <span className="flex items-center gap-1 rounded-md border border-border bg-background px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground shadow-sm">
8580
8597
  <IconCopy className="h-3 w-3" />
@@ -9411,6 +9428,14 @@ function GradientEditOverlay({
9411
9428
  );
9412
9429
  }
9413
9430
 
9431
+ /** The name shown in a frame's label row and in the alt-drag copy ghost. */
9432
+ function screenDisplayName(
9433
+ screen: ScreenFile,
9434
+ metadata: ResolvedScreenMetadata,
9435
+ ): string {
9436
+ return metadata.title ?? prettyScreenName(screen.filename);
9437
+ }
9438
+
9414
9439
  /** Standard Tailwind breakpoint widths, mobile-first (base / md: / lg: / xl:). */
9415
9440
  const STANDARD_BREAKPOINT_WIDTHS = [390, 768, 1280] as const;
9416
9441
 
@@ -9483,11 +9508,6 @@ interface ScreenProps {
9483
9508
  e: React.MouseEvent,
9484
9509
  ) => void;
9485
9510
  onStartRotate: (id: string, e: React.MouseEvent) => void;
9486
- onStartDuplicateGesture: (
9487
- screen: ScreenFile,
9488
- display: string,
9489
- e: React.MouseEvent<HTMLElement>,
9490
- ) => void;
9491
9511
  // Id-first (screenId, widthPx) shape, same as MultiScreenCanvas's own
9492
9512
  // onActiveBreakpointChange prop (PF18): Screen binds screen.id itself when
9493
9513
  // calling these, so the parent can pass the same stable function reference
@@ -9541,7 +9561,6 @@ const Screen = memo(function Screen({
9541
9561
  onStartFrameDrag,
9542
9562
  onStartResize,
9543
9563
  onStartRotate,
9544
- onStartDuplicateGesture,
9545
9564
  screenContent,
9546
9565
  renderBreakpointContent,
9547
9566
  cullTier,
@@ -9552,7 +9571,7 @@ const Screen = memo(function Screen({
9552
9571
  onEditBreakpoint,
9553
9572
  }: ScreenProps) {
9554
9573
  const t = useT();
9555
- const display = metadata.title ?? prettyScreenName(screen.filename);
9574
+ const display = screenDisplayName(screen, metadata);
9556
9575
  const previewUrl = metadata.previewUrl ?? getPreviewUrl(screen.content);
9557
9576
  const previewViewport = getScreenPreviewViewport(metadata, geometry);
9558
9577
  const suppressNextClick = useRef(false);
@@ -9684,15 +9703,11 @@ const Screen = memo(function Screen({
9684
9703
  return;
9685
9704
  }
9686
9705
  if (e.altKey) {
9687
- // Matches the data-screen-card mousedown handler below: without
9688
- // this, a trailing click after the alt-drag/duplicate gesture
9689
- // ends falls through to this row's onClick and steals selection
9690
- // away from the newly created duplicate.
9706
+ // Without this the trailing click after an alt-drag copy falls
9707
+ // through to this row's onClick and steals selection back from the
9708
+ // new duplicate.
9691
9709
  suppressNextClick.current = true;
9692
- onStartDuplicateGesture(screen, display, e);
9693
- return;
9694
- }
9695
- if (e.shiftKey) {
9710
+ } else if (e.shiftKey) {
9696
9711
  e.stopPropagation();
9697
9712
  return;
9698
9713
  }
@@ -9838,13 +9853,10 @@ const Screen = memo(function Screen({
9838
9853
  e.stopPropagation();
9839
9854
  return;
9840
9855
  }
9841
- if (e.altKey && e.button === 0) {
9842
- suppressNextClick.current = true;
9843
- onStartDuplicateGesture(screen, display, e);
9844
- return;
9845
- }
9846
9856
  if (e.button === 0) {
9847
- if (e.shiftKey) {
9857
+ if (e.altKey) {
9858
+ suppressNextClick.current = true;
9859
+ } else if (e.shiftKey) {
9848
9860
  e.stopPropagation();
9849
9861
  return;
9850
9862
  }
@@ -10105,7 +10117,6 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
10105
10117
  prev.onStartFrameDrag === next.onStartFrameDrag &&
10106
10118
  prev.onStartResize === next.onStartResize &&
10107
10119
  prev.onStartRotate === next.onStartRotate &&
10108
- prev.onStartDuplicateGesture === next.onStartDuplicateGesture &&
10109
10120
  // Now id-first (screenId, widthPx) callbacks passed straight through
10110
10121
  // from MultiScreenCanvas's own props (PF18) instead of a fresh
10111
10122
  // per-screen arrow allocated in the render loop, so these are expected
@@ -976,6 +976,9 @@ export interface ResolvedScreenMetadata {
976
976
 
977
977
  export interface DuplicatePreview {
978
978
  display: string;
979
+ /** How many frames the drop will copy — the whole frame selection when the
980
+ * alt-drag started on a selected frame, otherwise just the pressed one. */
981
+ count: number;
979
982
  x: number;
980
983
  y: number;
981
984
  width: number;