@dfosco/storyboard-react 4.2.0-beta.2 → 4.2.0-beta.21

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 (85) hide show
  1. package/package.json +9 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  3. package/src/BranchBar/BranchBar.jsx +20 -6
  4. package/src/BranchBar/BranchBar.module.css +13 -4
  5. package/src/BranchBar/useBranches.js +20 -6
  6. package/src/BranchBar/useBranches.test.js +68 -0
  7. package/src/CommandPalette/CommandPalette.jsx +478 -186
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +561 -191
  11. package/src/Viewfinder.module.css +434 -93
  12. package/src/Workspace.jsx +7 -0
  13. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  14. package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
  15. package/src/canvas/CanvasPage.jsx +738 -216
  16. package/src/canvas/CanvasPage.module.css +13 -15
  17. package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
  18. package/src/canvas/ConnectorLayer.jsx +121 -153
  19. package/src/canvas/ConnectorLayer.module.css +69 -0
  20. package/src/canvas/PageSelector.test.jsx +15 -6
  21. package/src/canvas/canvasApi.js +68 -2
  22. package/src/canvas/connectorGeometry.js +132 -0
  23. package/src/canvas/hotPoolDevLogs.js +25 -0
  24. package/src/canvas/useCanvas.js +1 -1
  25. package/src/canvas/useMarqueeSelect.js +30 -4
  26. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  27. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  28. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  29. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  30. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  31. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  32. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  33. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  34. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  35. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  38. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  39. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  40. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  41. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  42. package/src/canvas/widgets/LinkPreview.jsx +112 -4
  43. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  44. package/src/canvas/widgets/MarkdownBlock.jsx +164 -17
  45. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  46. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  47. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  48. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -38
  49. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  50. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  51. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  52. package/src/canvas/widgets/StoryWidget.jsx +72 -15
  53. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  54. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  55. package/src/canvas/widgets/TerminalWidget.jsx +496 -69
  56. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  57. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  58. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  59. package/src/canvas/widgets/WidgetChrome.jsx +73 -153
  60. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  61. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  62. package/src/canvas/widgets/expandUtils.js +557 -0
  63. package/src/canvas/widgets/expandUtils.test.js +155 -0
  64. package/src/canvas/widgets/index.js +9 -0
  65. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  66. package/src/canvas/widgets/tilePool.js +23 -0
  67. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  68. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  70. package/src/canvas/widgets/tiles/leaf.png +0 -0
  71. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  73. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  75. package/src/canvas/widgets/widgetConfig.js +55 -4
  76. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  77. package/src/canvas/widgets/widgetProps.js +1 -0
  78. package/src/context.jsx +47 -19
  79. package/src/hooks/useConfig.js +14 -0
  80. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  81. package/src/index.js +8 -2
  82. package/src/story/ComponentSetPage.jsx +186 -0
  83. package/src/story/ComponentSetPage.module.css +121 -0
  84. package/src/story/StoryPage.jsx +32 -2
  85. package/src/vite/data-plugin.js +324 -30
@@ -6,11 +6,16 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
6
6
  import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
7
7
  import { getWidgetComponent } from './widgets/index.js'
8
8
  import { schemas, getDefaults } from './widgets/widgetProps.js'
9
- import { getFeatures, isResizable, getAnchorState, canAcceptConnection } from './widgets/widgetConfig.js'
9
+ import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnection, isSplitScreenCapable } from './widgets/widgetConfig.js'
10
10
  import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
11
11
  import { getPasteRules } from '@dfosco/storyboard-core'
12
+ import { isTerminalResizable, getTerminalDimensions } from '@dfosco/storyboard-core'
13
+ import { getFlag } from '@dfosco/storyboard-core'
14
+ import { getCanvasZoom } from '@dfosco/storyboard-core'
12
15
  import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
16
+ import { registerHotPoolDevLogs } from './hotPoolDevLogs.js'
13
17
  import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
18
+
14
19
  import WidgetChrome from './widgets/WidgetChrome.jsx'
15
20
  import ComponentWidget from './widgets/ComponentWidget.jsx'
16
21
  import useUndoRedo from './useUndoRedo.js'
@@ -19,6 +24,7 @@ import MarqueeOverlay from './MarqueeOverlay.jsx'
19
24
  import {
20
25
  addWidget as addWidgetApi,
21
26
  checkGitHubCliAvailable,
27
+ duplicateImage,
22
28
  fetchGitHubEmbed,
23
29
  getCanvas as getCanvasApi,
24
30
  removeWidget as removeWidgetApi,
@@ -27,6 +33,8 @@ import {
27
33
  uploadImage,
28
34
  addConnector as addConnectorApi,
29
35
  removeConnector as removeConnectorApi,
36
+ updateConnector as updateConnectorApi,
37
+ batchOperations,
30
38
  } from './canvasApi.js'
31
39
  import PageSelector from './PageSelector.jsx'
32
40
  import Icon from '../Icon.jsx'
@@ -34,8 +42,11 @@ import { stories as storyIndex } from 'virtual:storyboard-data-index'
34
42
  import styles from './CanvasPage.module.css'
35
43
  import ConnectorLayer from './ConnectorLayer.jsx'
36
44
 
37
- const ZOOM_MIN = 25
38
- const ZOOM_MAX = 200
45
+ /** Canvas zoom limits — read from storyboard.config.json via canvasConfig. */
46
+ function zoomLimits() {
47
+ const z = getCanvasZoom()
48
+ return { ZOOM_MIN: z.min, ZOOM_MAX: z.max, ZOOM_STEP: z.step }
49
+ }
39
50
 
40
51
  /** Saved viewport state older than this is considered stale — zoom-to-fit instead. */
41
52
  const VIEWPORT_TTL_MS = 15 * 60 * 1000
@@ -44,6 +55,7 @@ const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
44
55
  const GH_INSTALL_URL = 'https://github.com/cli/cli'
45
56
 
46
57
  registerSmoothCorners()
58
+ registerHotPoolDevLogs()
47
59
 
48
60
  /** Matches branch-deploy base path prefixes like /branch--my-feature/ */
49
61
  const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
@@ -132,16 +144,17 @@ function getViewportStorageKey(canvasId) {
132
144
  function loadViewportState(canvasId) {
133
145
  try {
134
146
  const raw = localStorage.getItem(getViewportStorageKey(canvasId))
135
- if (!raw) { console.log('[viewport] no saved state for', canvasId); return null }
147
+ if (!raw) { if (getFlag('dev-logs')) console.log('[viewport] no saved state for', canvasId); return null }
136
148
  const state = JSON.parse(raw)
137
149
  const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
138
150
  const age = Date.now() - timestamp
139
151
  if (age > VIEWPORT_TTL_MS) {
140
- console.log('[viewport] stale state for', canvasId, '— age:', Math.round(age / 1000), 's')
152
+ if (getFlag('dev-logs')) console.log('[viewport] stale state for', canvasId, '— age:', Math.round(age / 1000), 's')
141
153
  localStorage.removeItem(getViewportStorageKey(canvasId))
142
154
  return null
143
155
  }
144
- console.log('[viewport] loaded state for', canvasId, '— age:', Math.round(age / 1000), 's, zoom:', state.zoom, 'scroll:', state.scrollLeft, state.scrollTop)
156
+ if (getFlag('dev-logs')) console.log('[viewport] loaded state for', canvasId, '— age:', Math.round(age / 1000), 's, zoom:', state.zoom, 'scroll:', state.scrollLeft, state.scrollTop)
157
+ const { ZOOM_MIN, ZOOM_MAX } = zoomLimits()
145
158
  return {
146
159
  zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
147
160
  scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
@@ -271,13 +284,15 @@ function computeCanvasBounds(widgets, componentEntries) {
271
284
  }
272
285
 
273
286
  /** Renders a single JSON-defined widget by type lookup. */
274
- function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub }) {
287
+ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub, multiSelected }) {
275
288
  const Component = getWidgetComponent(widget.type)
276
289
  if (!Component) {
277
290
  console.warn(`[canvas] Unknown widget type: ${widget.type}`)
278
291
  return null
279
292
  }
280
- const resizable = isResizable(widget.type) && !!onUpdate
293
+ const resizable = (widget.type === 'terminal' || widget.type === 'agent')
294
+ ? isTerminalResizable(widget.props?.agentId) && !!onUpdate
295
+ : isResizable(widget.type) && !!onUpdate
281
296
  // Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
282
297
  const elementProps = {
283
298
  id: widget.id,
@@ -286,6 +301,7 @@ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefre
286
301
  resizable,
287
302
  onRefreshGitHub,
288
303
  canRefreshGitHub,
304
+ multiSelected,
289
305
  }
290
306
  if (Component.$$typeof === Symbol.for('react.forward_ref')) {
291
307
  elementProps.ref = widgetRef
@@ -303,11 +319,14 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
303
319
  widget,
304
320
  selected,
305
321
  multiSelected,
322
+ connectorCount,
323
+ allWidgets,
306
324
  onSelect,
307
325
  onDeselect,
308
326
  onUpdate,
309
327
  onRemove,
310
328
  onCopy,
329
+ onCopyWithConnectors,
311
330
  onRefreshGitHub,
312
331
  canRefreshGitHub,
313
332
  onConnectorDragStart,
@@ -319,7 +338,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
319
338
  // Dynamically adjust features based on widget state
320
339
  const features = useMemo(() => {
321
340
  const isGitHub = !!widget.props?.github
322
- return rawFeatures.map((f) => {
341
+ const adjusted = rawFeatures.map((f) => {
323
342
  // Toggle collapse label and hide when content is short (no github = no collapse)
324
343
  if (f.action === 'toggle-collapse') {
325
344
  if (widget.type === 'link-preview' && !isGitHub) return null
@@ -333,13 +352,78 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
333
352
  if (f.action === 'refresh-github' && !isGitHub) return null
334
353
  return f
335
354
  }).filter(Boolean)
336
- }, [rawFeatures, widget.props?.github, widget.props?.collapsed])
337
355
 
338
- const handleAction = useCallback((actionId) => {
356
+ // Add dynamic "Split Screen" action when a connected split target exists.
357
+ // Uses connectorCount/allWidgets props (reactive) instead of the global
358
+ // bridge state which may be stale during React render.
359
+ if (isExpandable(widget.type)) {
360
+ const hasConnected = (connectorCount || []).some((c) => {
361
+ const otherId = c.start?.widgetId === widget.id ? c.end?.widgetId : c.start?.widgetId
362
+ const otherWidget = (allWidgets || []).find((w) => w.id === otherId)
363
+ return otherWidget && isSplitScreenCapable(otherWidget.type)
364
+ })
365
+ if (hasConnected) {
366
+ // Insert before the first menu-only feature
367
+ const insertIdx = adjusted.findIndex((f) => f.menu)
368
+ const splitFeature = {
369
+ id: 'split-screen',
370
+ type: 'action',
371
+ action: 'split-screen',
372
+ label: 'Split Screen',
373
+ icon: 'columns',
374
+ prod: true,
375
+ }
376
+ if (insertIdx >= 0) adjusted.splice(insertIdx, 0, splitFeature)
377
+ else adjusted.push(splitFeature)
378
+ }
379
+ }
380
+
381
+ // Add dynamic "Broadcast" toggle for terminal/agent widgets with connected peers
382
+ if (widget.type === 'terminal' || widget.type === 'agent') {
383
+ const widgetConnectors = connectorCount || []
384
+ const widgetList = allWidgets || []
385
+ let hasBroadcastPeers = false
386
+ let allBroadcastActive = true
387
+ const broadcastConnectorIds = []
388
+
389
+ for (const conn of widgetConnectors) {
390
+ const peerId = conn.start?.widgetId === widget.id ? conn.end?.widgetId : conn.start?.widgetId
391
+ const peer = widgetList.find((w) => w.id === peerId)
392
+ if (peer && (peer.type === 'terminal' || peer.type === 'agent')) {
393
+ hasBroadcastPeers = true
394
+ broadcastConnectorIds.push(conn.id)
395
+ if (conn.meta?.messagingMode !== 'two-way') allBroadcastActive = false
396
+ }
397
+ }
398
+
399
+ if (hasBroadcastPeers) {
400
+ const isActive = allBroadcastActive
401
+ const insertIdx = adjusted.findIndex((f) => f.menu)
402
+ const broadcastFeature = {
403
+ id: 'broadcast',
404
+ type: 'action',
405
+ action: `broadcast-toggle:${broadcastConnectorIds.join(',')}:${isActive ? 'off' : 'on'}`,
406
+ label: isActive ? 'Broadcast On' : 'Broadcast',
407
+ icon: 'broadcast',
408
+ active: isActive,
409
+ }
410
+ if (insertIdx >= 0) adjusted.splice(insertIdx, 0, broadcastFeature)
411
+ else adjusted.push(broadcastFeature)
412
+ }
413
+ }
414
+
415
+ return adjusted
416
+ }, [rawFeatures, widget.props?.github, widget.props?.collapsed, widget.type, widget.id, connectorCount, allWidgets])
417
+
418
+ const handleAction = useCallback((actionId, opts) => {
339
419
  if (actionId === 'delete') {
340
420
  onRemove?.(widget.id)
341
421
  } else if (actionId === 'copy') {
342
- onCopy?.(widget)
422
+ if (opts?.altKey && onCopyWithConnectors) {
423
+ onCopyWithConnectors(widget)
424
+ } else {
425
+ onCopy?.(widget)
426
+ }
343
427
  } else if (actionId === 'copy-text') {
344
428
  const title = widget.props?.title || ''
345
429
  const body = widget.props?.text || widget.props?.content || widget.props?.github?.body || ''
@@ -361,8 +445,20 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
361
445
  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
362
446
  })
363
447
  }
448
+ } else if (actionId.startsWith('broadcast-toggle:')) {
449
+ // broadcast-toggle:<connectorId1,connectorId2,...>:<on|off>
450
+ const parts = actionId.split(':')
451
+ const connectorIds = parts[1].split(',')
452
+ const turnOn = parts[2] === 'on'
453
+ const bridge = window.__storyboardCanvasBridgeState
454
+ const canvasId = bridge?.canvasId || ''
455
+ const meta = turnOn ? { messagingMode: 'two-way' } : { messagingMode: null }
456
+ for (const cid of connectorIds) {
457
+ updateConnectorApi(canvasId, cid, meta)
458
+ .catch((err) => console.error('[canvas] Failed to toggle broadcast:', err))
459
+ }
364
460
  }
365
- }, [widget, onRemove, onCopy, onRefreshGitHub])
461
+ }, [widget, onRemove, onCopy, onCopyWithConnectors, onRefreshGitHub])
366
462
 
367
463
  const handleWidgetFieldUpdate = useCallback((updates) => {
368
464
  onUpdate?.(widget.id, updates)
@@ -390,6 +486,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
390
486
  widgetRef={widgetRef}
391
487
  onRefreshGitHub={onRefreshGitHub}
392
488
  canRefreshGitHub={canRefreshGitHub}
489
+ multiSelected={multiSelected}
393
490
  />
394
491
  </WidgetChrome>
395
492
  )
@@ -398,6 +495,8 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
398
495
  prev.widget === next.widget &&
399
496
  prev.selected === next.selected &&
400
497
  prev.multiSelected === next.multiSelected &&
498
+ prev.connectorCount === next.connectorCount &&
499
+ prev.allWidgets === next.allWidgets &&
401
500
  prev.readOnly === next.readOnly &&
402
501
  prev.onSelect === next.onSelect &&
403
502
  prev.onDeselect === next.onDeselect &&
@@ -532,6 +631,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
532
631
  const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
533
632
  const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
534
633
 
634
+ // Scroll lock: prevents focus-triggered scroll jumps when adding terminal/agent widgets.
635
+ // The lock captures the current scroll position and forces it back on every scroll event
636
+ // until unlocked by the widget's ready signal or a safety timeout.
637
+ // Visual UI (outline + banner) only appears after 1.5s if still locked.
638
+
535
639
  // Refs for snap settings (used by drop handler inside effect closure)
536
640
  const snapEnabledRef = useRef(snapEnabled)
537
641
  const snapGridSizeRef = useRef(snapGridSize)
@@ -571,12 +675,42 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
571
675
  stateRef.current = { widgets: localWidgets, sources: localSources, connectors: localConnectors }
572
676
  }, [localWidgets, localSources, localConnectors])
573
677
 
678
+ // Dirty flag — true while optimistic edits haven't been persisted yet.
679
+ // Prevents HMR echoes from overwriting in-flight local state.
680
+ const dirtyRef = useRef(false)
681
+
682
+ // Counter of in-flight writes. dirtyRef is only cleared when this reaches 0,
683
+ // preventing early clears when multiple writes are queued in sequence.
684
+ const inflightWritesRef = useRef(0)
685
+
686
+ // Grace period timer — after all writes complete, dirtyRef stays true for a
687
+ // brief window to absorb delayed file-watcher HMR events that arrive after
688
+ // the server's immediate push. Defense-in-depth for the write guard.
689
+ const dirtyGraceTimerRef = useRef(null)
690
+
574
691
  // Serialized write queue — ensures JSONL events land in the right order
575
692
  const writeQueueRef = useRef(Promise.resolve())
576
693
  function queueWrite(fn) {
577
- writeQueueRef.current = writeQueueRef.current.then(fn).catch((err) =>
578
- console.error('[canvas] Write queue error:', err)
579
- )
694
+ clearTimeout(dirtyGraceTimerRef.current)
695
+ inflightWritesRef.current += 1
696
+ writeQueueRef.current = writeQueueRef.current
697
+ .then(fn)
698
+ .catch((err) => console.error('[canvas] Write queue error:', err))
699
+ .finally(() => {
700
+ inflightWritesRef.current -= 1
701
+ if (inflightWritesRef.current < 0) {
702
+ console.warn('[canvas] Write queue counter underflow — resetting')
703
+ inflightWritesRef.current = 0
704
+ }
705
+ if (inflightWritesRef.current === 0) {
706
+ // Grace period — absorb delayed watcher HMR events before clearing
707
+ dirtyGraceTimerRef.current = setTimeout(() => {
708
+ if (inflightWritesRef.current === 0) {
709
+ dirtyRef.current = false
710
+ }
711
+ }, 600)
712
+ }
713
+ })
580
714
  return writeQueueRef.current
581
715
  }
582
716
 
@@ -663,14 +797,23 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
663
797
 
664
798
  if (canvas !== trackedCanvas) {
665
799
  const isCanvasSwitch = trackedCanvas && canvas && trackedCanvas._route !== canvas._route
666
- console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
800
+ if (getFlag('dev-logs')) console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
667
801
  setTrackedCanvas(canvas)
668
- setLocalWidgets(canvas?.widgets ?? null)
669
- setLocalConnectors(canvas?.connectors ?? [])
670
- setLocalSources(canvas?.sources ?? [])
802
+
803
+ // Skip replacing local state with server data when optimistic edits are
804
+ // pending — the local state is more recent. The next save will persist it
805
+ // and the subsequent server push (after dirty clears) will reconcile.
806
+ if (!dirtyRef.current || isCanvasSwitch) {
807
+ setLocalWidgets(canvas?.widgets ?? null)
808
+ setLocalConnectors(canvas?.connectors ?? [])
809
+ setLocalSources(canvas?.sources ?? [])
810
+ }
811
+
671
812
  setSnapEnabled(canvas?.snapToGrid ?? false)
672
813
  setSnapGridSize(canvas?.gridSize || 40)
673
- undoRedo.reset()
814
+ if (isCanvasSwitch) {
815
+ undoRedo.reset()
816
+ }
674
817
  // Only reset viewport state when switching to a different canvas,
675
818
  // not when the same canvas refreshes with server data.
676
819
  if (isCanvasSwitch) {
@@ -683,11 +826,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
683
826
  }
684
827
  }
685
828
 
686
- // Debounced save to server
829
+ // Debounced save to server — routed through queueWrite to serialize
830
+ // with deletes and other writes, preventing stale data from overwriting.
687
831
  const debouncedSave = useRef(
688
832
  debounce((canvasId, widgets) => {
689
- updateCanvas(canvasId, { widgets }).catch((err) =>
690
- console.error('[canvas] Failed to save:', err)
833
+ queueWrite(() =>
834
+ updateCanvas(canvasId, { widgets })
835
+ .catch((err) => console.error('[canvas] Failed to save:', err))
691
836
  )
692
837
  }, 2000)
693
838
  ).current
@@ -705,12 +850,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
705
850
  const next = prev.map((w) =>
706
851
  w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
707
852
  )
853
+ dirtyRef.current = true
708
854
  debouncedSave(canvasId, next)
709
855
  return next
710
856
  })
711
857
  }, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
712
858
 
713
859
  const handleWidgetRemove = useCallback((widgetId) => {
860
+ // Cancel any pending debounced save — it may contain stale data
861
+ // that includes the widget we're about to delete
862
+ debouncedSave.cancel()
863
+
714
864
  undoRedo.snapshot(stateRef.current, 'remove', widgetId)
715
865
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
716
866
  // Cascade: remove connectors referencing this widget
@@ -726,12 +876,12 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
726
876
  }
727
877
  return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
728
878
  })
879
+ dirtyRef.current = true
729
880
  queueWrite(() =>
730
- removeWidgetApi(canvasId, widgetId).catch((err) =>
731
- console.error('[canvas] Failed to remove widget:', err)
732
- )
881
+ removeWidgetApi(canvasId, widgetId)
882
+ .catch((err) => console.error('[canvas] Failed to remove widget:', err))
733
883
  )
734
- }, [canvasId, undoRedo])
884
+ }, [canvasId, undoRedo, debouncedSave])
735
885
 
736
886
  const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
737
887
  try {
@@ -748,6 +898,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
748
898
  const handleConnectorRemove = useCallback((connectorId) => {
749
899
  undoRedo.snapshot(stateRef.current, 'connector-remove')
750
900
  setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
901
+ dirtyRef.current = true
751
902
  queueWrite(() =>
752
903
  removeConnectorApi(canvasId, connectorId).catch((err) =>
753
904
  console.error('[canvas] Failed to remove connector:', err)
@@ -802,21 +953,52 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
802
953
  y: (scrollEl.scrollTop + clientY - rect.top) / scale,
803
954
  })
804
955
 
805
- // Find nearest anchor on any other widget within snap distance
806
- const SNAP_DIST = 40
956
+ // Find nearest anchor on any other widget within a rectangular snap zone.
957
+ // Each anchor has a 30px-wide strip (15px each side) extending from the widget edge.
958
+ const SNAP_EXTEND = 15
959
+ const SNAP_DEPTH = 40
960
+ const SNAP_CROSS = 20 // perpendicular expansion so you can approach from any direction
807
961
  const sourceType = startWidget.type
808
962
  const findNearestAnchor = (canvasPt) => {
809
963
  const currentWidgets = stateRef.current.widgets ?? []
810
964
  let best = null
811
- let bestDist = SNAP_DIST
965
+ let bestDist = Infinity
812
966
  for (const w of currentWidgets) {
813
967
  if (w.id === widgetId) continue
814
- // Check if this widget type accepts connections from the source type
815
968
  if (!canAcceptConnection(w.type, sourceType)) continue
969
+
970
+ let ww, wh
971
+ const el = document.getElementById(w.id)
972
+ if (el) {
973
+ const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
974
+ if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
975
+ }
976
+ if (!ww) ww = w.props?.width ?? w.bounds?.width ?? 270
977
+ if (!wh) wh = w.props?.height ?? w.bounds?.height ?? 170
978
+ const wx = w.position?.x ?? 0
979
+ const wy = w.position?.y ?? 0
980
+
816
981
  for (const anch of ['top', 'bottom', 'left', 'right']) {
817
- // Skip unavailable or disabled anchors
818
982
  const anchorState = getAnchorState(w.type, anch)
819
983
  if (anchorState !== 'available') continue
984
+
985
+ // Build a rectangular hit zone for this anchor
986
+ let inZone = false
987
+ if (anch === 'top') {
988
+ inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
989
+ canvasPt.y >= wy - SNAP_DEPTH && canvasPt.y <= wy + SNAP_EXTEND
990
+ } else if (anch === 'bottom') {
991
+ inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
992
+ canvasPt.y >= wy + wh - SNAP_EXTEND && canvasPt.y <= wy + wh + SNAP_DEPTH
993
+ } else if (anch === 'left') {
994
+ inZone = canvasPt.x >= wx - SNAP_DEPTH && canvasPt.x <= wx + SNAP_EXTEND &&
995
+ canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
996
+ } else if (anch === 'right') {
997
+ inZone = canvasPt.x >= wx + ww - SNAP_EXTEND && canvasPt.x <= wx + ww + SNAP_DEPTH &&
998
+ canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
999
+ }
1000
+ if (!inZone) continue
1001
+
820
1002
  const pt = computeAnchorPt(w, anch)
821
1003
  const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
822
1004
  if (dist < bestDist) {
@@ -872,147 +1054,334 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
872
1054
  document.addEventListener('pointerup', handlePointerUp)
873
1055
  }, [handleConnectorAdd])
874
1056
 
875
- // Drag an existing connector endpoint to reconnect or remove
876
- const handleEndpointDrag = useCallback((connector, endpoint, e) => {
877
- e.stopPropagation()
878
- e.preventDefault()
879
- const scrollEl = scrollRef.current
880
- if (!scrollEl) return
881
- const scale = zoomRef.current / 100
882
- const rect = scrollEl.getBoundingClientRect()
883
-
884
- // The fixed end stays put; the dragged end follows cursor
885
- const fixedEnd = endpoint === 'start' ? 'end' : 'start'
886
- const fixedSide = connector[fixedEnd]
887
- const fixedWidget = (stateRef.current.widgets ?? []).find((w) => w.id === fixedSide.widgetId)
888
- if (!fixedWidget) return
1057
+ // Endpoint drag removed dragging from a filled anchor now always
1058
+ // creates a new connection via handleConnectorDragStart instead of
1059
+ // repositioning the existing one.
889
1060
 
890
- const computeAnchorPtLocal = (widget, anch) => {
891
- let ww, wh
892
- const el = document.getElementById(widget.id)
893
- if (el) {
894
- const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
895
- if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
1061
+ const handleWidgetCopy = useCallback(async (widget) => {
1062
+ // Find the next free offset — check how many copies already exist at +n*40
1063
+ const baseX = widget.position?.x ?? 0
1064
+ const baseY = widget.position?.y ?? 0
1065
+ const occupied = new Set(
1066
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
1067
+ )
1068
+ let n = 1
1069
+ while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
1070
+ n++
1071
+ }
1072
+ const position = { x: baseX + n * 40, y: baseY + n * 40 }
1073
+ const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
1074
+ try {
1075
+ const copyProps = { ...widget.props }
1076
+ // Terminal widgets must get unique names — strip prettyName so the server generates a fresh one
1077
+ if (isTerminal) delete copyProps.prettyName
1078
+ // Image widgets: duplicate the asset file so each widget owns its own copy
1079
+ if (widget.type === 'image' && copyProps.src) {
1080
+ const dupResult = await duplicateImage(copyProps.src)
1081
+ if (dupResult.success) copyProps.src = dupResult.filename
896
1082
  }
897
- if (!ww) ww = widget.props?.width ?? widget.bounds?.width ?? 270
898
- if (!wh) wh = widget.props?.height ?? widget.bounds?.height ?? 170
899
- const px = widget.position?.x ?? 0
900
- const py = widget.position?.y ?? 0
901
- switch (anch) {
902
- case 'top': return { x: px + ww / 2, y: py }
903
- case 'bottom': return { x: px + ww / 2, y: py + wh }
904
- case 'left': return { x: px, y: py + wh / 2 }
905
- case 'right': return { x: px + ww, y: py + wh / 2 }
906
- default: return { x: px + ww / 2, y: py + wh / 2 }
1083
+
1084
+ undoRedo.snapshot(stateRef.current, 'add')
1085
+ const result = await addWidgetApi(canvasId, {
1086
+ type: widget.type,
1087
+ props: copyProps,
1088
+ position,
1089
+ })
1090
+ if (result.success && result.widget) {
1091
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1092
+ setSelectedWidgetIds(new Set([result.widget.id]))
907
1093
  }
1094
+ } catch (err) {
1095
+ console.error('[canvas] Failed to copy widget:', err)
908
1096
  }
1097
+ }, [canvasId, localWidgets, undoRedo])
909
1098
 
910
- const fixedPt = computeAnchorPtLocal(fixedWidget, fixedSide.anchor)
911
- const fixedWidgetId = fixedSide.widgetId
1099
+ // Duplicate a single widget WITH its connectors (Alt+click on duplicate button)
1100
+ const handleWidgetCopyWithConnectors = useCallback(async (widget) => {
1101
+ if (!widget) return
1102
+ const widgets = [widget]
912
1103
 
913
- const toCanvasPoint = (clientX, clientY) => ({
914
- x: (scrollEl.scrollLeft + clientX - rect.left) / scale,
915
- y: (scrollEl.scrollTop + clientY - rect.top) / scale,
916
- })
1104
+ undoRedo.snapshot(stateRef.current, 'add')
917
1105
 
918
- const SNAP_DIST = 40
919
- const sourceType = fixedWidget.type
920
- const findNearestAnchorLocal = (canvasPt) => {
921
- const currentWidgets = stateRef.current.widgets ?? []
922
- let best = null
923
- let bestDist = SNAP_DIST
924
- for (const w of currentWidgets) {
925
- if (w.id === fixedWidgetId) continue
926
- if (!canAcceptConnection(w.type, sourceType)) continue
927
- for (const anch of ['top', 'bottom', 'left', 'right']) {
928
- const anchorState = getAnchorState(w.type, anch)
929
- if (anchorState !== 'available') continue
930
- const pt = computeAnchorPtLocal(w, anch)
931
- const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
932
- if (dist < bestDist) {
933
- bestDist = dist
934
- best = { widgetId: w.id, anchor: anch, pt }
1106
+ const occupied = new Set(
1107
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
1108
+ )
1109
+ let offset = 1
1110
+ while (occupied.has(`${(widget.position?.x ?? 0) + offset * 40},${(widget.position?.y ?? 0) + offset * 40}`)) offset++
1111
+
1112
+ const imageOverrides = new Map()
1113
+ if (widget.type === 'image' && widget.props?.src) {
1114
+ try {
1115
+ const dupResult = await duplicateImage(widget.props.src)
1116
+ if (dupResult.success) imageOverrides.set(widget.id, dupResult.filename)
1117
+ } catch { /* use original src as fallback */ }
1118
+ }
1119
+
1120
+ const selectedIds = new Set([widget.id])
1121
+ const relevantConnectors = (localConnectors ?? []).filter(
1122
+ (c) => selectedIds.has(c.start?.widgetId) || selectedIds.has(c.end?.widgetId)
1123
+ )
1124
+
1125
+ const ops = []
1126
+ for (const w of widgets) {
1127
+ const copyProps = { ...w.props }
1128
+ const isTerminal = w.type === 'terminal' || w.type === 'agent'
1129
+ if (isTerminal) delete copyProps.prettyName
1130
+ if (imageOverrides.has(w.id)) copyProps.src = imageOverrides.get(w.id)
1131
+ ops.push({
1132
+ op: 'create-widget',
1133
+ ref: `clone-${w.id}`,
1134
+ type: w.type,
1135
+ props: copyProps,
1136
+ position: {
1137
+ x: (w.position?.x ?? 0) + offset * 40,
1138
+ y: (w.position?.y ?? 0) + offset * 40,
1139
+ },
1140
+ })
1141
+ }
1142
+
1143
+ for (const conn of relevantConnectors) {
1144
+ const startInSelection = selectedIds.has(conn.start?.widgetId)
1145
+ const endInSelection = selectedIds.has(conn.end?.widgetId)
1146
+ ops.push({
1147
+ op: 'create-connector',
1148
+ startWidgetId: startInSelection ? `$clone-${conn.start.widgetId}` : conn.start.widgetId,
1149
+ startAnchor: conn.start.anchor,
1150
+ endWidgetId: endInSelection ? `$clone-${conn.end.widgetId}` : conn.end.widgetId,
1151
+ endAnchor: conn.end.anchor,
1152
+ connectorType: conn.connectorType || 'default',
1153
+ })
1154
+ }
1155
+
1156
+ try {
1157
+ const response = await batchOperations(canvasId, ops)
1158
+ if (!response.success) {
1159
+ console.error('[canvas] Batch duplicate failed:', response.error)
1160
+ return
1161
+ }
1162
+
1163
+ const newWidgets = []
1164
+ const newConnectors = []
1165
+ const refMap = response.refs || {}
1166
+
1167
+ for (const result of response.results) {
1168
+ if (result.op === 'create-widget' && result.widget) {
1169
+ newWidgets.push(result.widget)
1170
+ }
1171
+ if (result.op === 'create-connector' && result.connectorId) {
1172
+ const origOp = ops[result.index]
1173
+ const resolveId = (val) => {
1174
+ if (typeof val === 'string' && val.startsWith('$')) {
1175
+ return refMap[val.slice(1)] ?? val
1176
+ }
1177
+ return val
935
1178
  }
1179
+ newConnectors.push({
1180
+ id: result.connectorId,
1181
+ type: 'connector',
1182
+ connectorType: origOp.connectorType || 'default',
1183
+ start: { widgetId: resolveId(origOp.startWidgetId), anchor: origOp.startAnchor },
1184
+ end: { widgetId: resolveId(origOp.endWidgetId), anchor: origOp.endAnchor },
1185
+ meta: {},
1186
+ })
936
1187
  }
937
1188
  }
938
- return best
939
- }
940
-
941
- const cursorPt = toCanvasPoint(e.clientX, e.clientY)
942
- const snap = findNearestAnchorLocal(cursorPt)
943
- setConnectorDrag({
944
- startWidgetId: fixedWidgetId,
945
- startAnchor: fixedSide.anchor,
946
- startPt: fixedPt,
947
- endPt: snap ? snap.pt : cursorPt,
948
- endAnchor: snap ? snap.anchor : fixedSide.anchor,
949
- snapTarget: snap,
950
- })
951
1189
 
952
- const handlePointerMove = (moveE) => {
953
- const pt = toCanvasPoint(moveE.clientX, moveE.clientY)
954
- const nearSnap = findNearestAnchorLocal(pt)
955
- setConnectorDrag((prev) => prev ? {
956
- ...prev,
957
- endPt: nearSnap ? nearSnap.pt : pt,
958
- endAnchor: nearSnap ? nearSnap.anchor : prev.startAnchor,
959
- snapTarget: nearSnap,
960
- } : null)
1190
+ if (newWidgets.length > 0) {
1191
+ setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1192
+ setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
1193
+ }
1194
+ if (newConnectors.length > 0) {
1195
+ setLocalConnectors((prev) => [...prev, ...newConnectors])
1196
+ }
1197
+ } catch (err) {
1198
+ console.error('[canvas] Failed to duplicate with connectors:', err)
961
1199
  }
1200
+ }, [canvasId, localWidgets, localConnectors, undoRedo])
962
1201
 
963
- const handlePointerUp = (upE) => {
964
- document.removeEventListener('pointermove', handlePointerMove)
965
- document.removeEventListener('pointerup', handlePointerUp)
1202
+ // Duplicate all selected widgets in one undo step (Cmd+D)
1203
+ const handleDuplicateSelected = useCallback(async () => {
1204
+ const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
1205
+ if (widgets.length === 0) return
966
1206
 
967
- const pt = toCanvasPoint(upE.clientX, upE.clientY)
968
- const nearSnap = findNearestAnchorLocal(pt)
1207
+ // Single undo snapshot for the entire batch
1208
+ undoRedo.snapshot(stateRef.current, 'add')
969
1209
 
970
- // Always remove the old connector
971
- handleConnectorRemove(connector.id)
1210
+ // Compute occupied positions to find free offset
1211
+ const occupied = new Set(
1212
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
1213
+ )
1214
+ let offset = 1
1215
+ const anyOccupied = () => widgets.some((w) => {
1216
+ const bx = (w.position?.x ?? 0) + offset * 40
1217
+ const by = (w.position?.y ?? 0) + offset * 40
1218
+ return occupied.has(`${bx},${by}`)
1219
+ })
1220
+ while (anyOccupied()) offset++
972
1221
 
973
- // If snapped to a new anchor, create a new connector
974
- if (nearSnap) {
975
- handleConnectorAdd({
976
- startWidgetId: fixedWidgetId,
977
- startAnchor: fixedSide.anchor,
978
- endWidgetId: nearSnap.widgetId,
979
- endAnchor: nearSnap.anchor,
1222
+ const newWidgets = []
1223
+ for (const widget of widgets) {
1224
+ const position = {
1225
+ x: (widget.position?.x ?? 0) + offset * 40,
1226
+ y: (widget.position?.y ?? 0) + offset * 40,
1227
+ }
1228
+ const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
1229
+ try {
1230
+ const copyProps = { ...widget.props }
1231
+ if (isTerminal) delete copyProps.prettyName
1232
+ if (widget.type === 'image' && copyProps.src) {
1233
+ try {
1234
+ const dupResult = await duplicateImage(copyProps.src)
1235
+ if (dupResult.success) copyProps.src = dupResult.filename
1236
+ } catch { /* use original src as fallback */ }
1237
+ }
1238
+ const result = await addWidgetApi(canvasId, {
1239
+ type: widget.type,
1240
+ props: copyProps,
1241
+ position,
980
1242
  })
1243
+ if (result.success && result.widget) {
1244
+ newWidgets.push(result.widget)
1245
+ }
1246
+ } catch (err) {
1247
+ console.error('[canvas] Failed to duplicate widget:', err)
981
1248
  }
982
- setConnectorDrag(null)
983
1249
  }
984
1250
 
985
- document.addEventListener('pointermove', handlePointerMove)
986
- document.addEventListener('pointerup', handlePointerUp)
987
- }, [handleConnectorAdd, handleConnectorRemove])
1251
+ if (newWidgets.length > 0) {
1252
+ setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1253
+ setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
1254
+ }
1255
+ }, [canvasId, localWidgets, selectedWidgetIds, undoRedo])
988
1256
 
989
- const handleWidgetCopy = useCallback(async (widget) => {
990
- // Find the next free offset check how many copies already exist at +n*40
991
- const baseX = widget.position?.x ?? 0
992
- const baseY = widget.position?.y ?? 0
1257
+ // Duplicate selected widgets WITH connectors (Cmd+Shift+D)
1258
+ // Uses the batch API for atomic operation all widgets and connectors
1259
+ // are created in a single request with $ref resolution.
1260
+ const handleDuplicateWithConnectors = useCallback(async () => {
1261
+ const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
1262
+ if (widgets.length === 0) return
1263
+
1264
+ undoRedo.snapshot(stateRef.current, 'add')
1265
+
1266
+ // Compute offset — same logic as handleDuplicateSelected
993
1267
  const occupied = new Set(
994
1268
  (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
995
1269
  )
996
- let n = 1
997
- while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
998
- n++
1270
+ let offset = 1
1271
+ const anyOccupied = () => widgets.some((w) => {
1272
+ const bx = (w.position?.x ?? 0) + offset * 40
1273
+ const by = (w.position?.y ?? 0) + offset * 40
1274
+ return occupied.has(`${bx},${by}`)
1275
+ })
1276
+ while (anyOccupied()) offset++
1277
+
1278
+ // Pre-process image widgets — duplicate asset files to get unique filenames
1279
+ const imageOverrides = new Map()
1280
+ for (const widget of widgets) {
1281
+ if (widget.type === 'image' && widget.props?.src) {
1282
+ try {
1283
+ const dupResult = await duplicateImage(widget.props.src)
1284
+ if (dupResult.success) imageOverrides.set(widget.id, dupResult.filename)
1285
+ } catch { /* use original src as fallback */ }
1286
+ }
999
1287
  }
1000
- const position = { x: baseX + n * 40, y: baseY + n * 40 }
1001
- try {
1002
- undoRedo.snapshot(stateRef.current, 'add')
1003
- const result = await addWidgetApi(canvasId, {
1288
+
1289
+ // Find all connectors touching at least one selected widget
1290
+ const selectedIds = new Set(widgets.map((w) => w.id))
1291
+ const relevantConnectors = (localConnectors ?? []).filter(
1292
+ (c) => selectedIds.has(c.start?.widgetId) || selectedIds.has(c.end?.widgetId)
1293
+ )
1294
+
1295
+ // Build batch operations
1296
+ const ops = []
1297
+
1298
+ // 1. Create-widget ops with ref names for $ref resolution
1299
+ for (const widget of widgets) {
1300
+ const copyProps = { ...widget.props }
1301
+ const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
1302
+ if (isTerminal) delete copyProps.prettyName
1303
+ if (imageOverrides.has(widget.id)) copyProps.src = imageOverrides.get(widget.id)
1304
+
1305
+ ops.push({
1306
+ op: 'create-widget',
1307
+ ref: `clone-${widget.id}`,
1004
1308
  type: widget.type,
1005
- props: { ...widget.props },
1006
- position,
1309
+ props: copyProps,
1310
+ position: {
1311
+ x: (widget.position?.x ?? 0) + offset * 40,
1312
+ y: (widget.position?.y ?? 0) + offset * 40,
1313
+ },
1007
1314
  })
1008
- if (result.success && result.widget) {
1009
- setLocalWidgets((prev) => [...(prev || []), result.widget])
1010
- setSelectedWidgetIds(new Set([result.widget.id]))
1315
+ }
1316
+
1317
+ // 2. Create-connector ops — remap selected endpoints to $ref clones
1318
+ for (const conn of relevantConnectors) {
1319
+ const startInSelection = selectedIds.has(conn.start?.widgetId)
1320
+ const endInSelection = selectedIds.has(conn.end?.widgetId)
1321
+
1322
+ ops.push({
1323
+ op: 'create-connector',
1324
+ startWidgetId: startInSelection ? `$clone-${conn.start.widgetId}` : conn.start.widgetId,
1325
+ startAnchor: conn.start.anchor,
1326
+ endWidgetId: endInSelection ? `$clone-${conn.end.widgetId}` : conn.end.widgetId,
1327
+ endAnchor: conn.end.anchor,
1328
+ connectorType: conn.connectorType || 'default',
1329
+ })
1330
+ }
1331
+
1332
+ try {
1333
+ const response = await batchOperations(canvasId, ops)
1334
+ if (!response.success) {
1335
+ console.error('[canvas] Batch duplicate failed:', response.error)
1336
+ return
1337
+ }
1338
+
1339
+ // Extract created widgets and connectors from results
1340
+ const newWidgets = []
1341
+ const newConnectors = []
1342
+ const refMap = response.refs || {}
1343
+
1344
+ for (const result of response.results) {
1345
+ if (result.op === 'create-widget' && result.widget) {
1346
+ newWidgets.push(result.widget)
1347
+ }
1348
+ if (result.op === 'create-connector' && result.connectorId) {
1349
+ // Reconstruct connector object from the operation + resolved refs
1350
+ const origOp = ops[result.index]
1351
+ const resolveId = (val) => {
1352
+ if (typeof val === 'string' && val.startsWith('$')) {
1353
+ return refMap[val.slice(1)] ?? val
1354
+ }
1355
+ return val
1356
+ }
1357
+ newConnectors.push({
1358
+ id: result.connectorId,
1359
+ type: 'connector',
1360
+ connectorType: origOp.connectorType || 'default',
1361
+ start: { widgetId: resolveId(origOp.startWidgetId), anchor: origOp.startAnchor },
1362
+ end: { widgetId: resolveId(origOp.endWidgetId), anchor: origOp.endAnchor },
1363
+ meta: {},
1364
+ })
1365
+ }
1366
+ }
1367
+
1368
+ if (newWidgets.length > 0) {
1369
+ setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1370
+ setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
1371
+ }
1372
+ if (newConnectors.length > 0) {
1373
+ setLocalConnectors((prev) => [...prev, ...newConnectors])
1011
1374
  }
1012
1375
  } catch (err) {
1013
- console.error('[canvas] Failed to copy widget:', err)
1376
+ console.error('[canvas] Failed to duplicate with connectors:', err)
1014
1377
  }
1015
- }, [canvasId, localWidgets, undoRedo])
1378
+ }, [canvasId, localWidgets, localConnectors, selectedWidgetIds, undoRedo])
1379
+
1380
+ // Select all widgets (Cmd+A)
1381
+ const handleSelectAll = useCallback(() => {
1382
+ const allIds = (localWidgets ?? []).map((w) => w.id)
1383
+ if (allIds.length > 0) setSelectedWidgetIds(new Set(allIds))
1384
+ }, [localWidgets])
1016
1385
 
1017
1386
  const showMissingGhBanner = useCallback(() => {
1018
1387
  setShowGhInstallBanner(true)
@@ -1068,8 +1437,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1068
1437
 
1069
1438
  const debouncedSourceSave = useRef(
1070
1439
  debounce((canvasId, sources) => {
1071
- updateCanvas(canvasId, { sources }).catch((err) =>
1072
- console.error('[canvas] Failed to save sources:', err)
1440
+ queueWrite(() =>
1441
+ updateCanvas(canvasId, { sources }).catch((err) =>
1442
+ console.error('[canvas] Failed to save sources:', err)
1443
+ )
1073
1444
  )
1074
1445
  }, 2000)
1075
1446
  ).current
@@ -1086,6 +1457,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1086
1457
  const next = current.some((s) => s?.export === exportName)
1087
1458
  ? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
1088
1459
  : [...current, { export: exportName, ...snapped }]
1460
+ dirtyRef.current = true
1089
1461
  debouncedSourceSave(canvasId, next)
1090
1462
  return next
1091
1463
  })
@@ -1141,10 +1513,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1141
1513
  }
1142
1514
  return w
1143
1515
  })
1516
+ dirtyRef.current = true
1144
1517
  queueWrite(() =>
1145
- updateCanvas(canvasId, { widgets: next }).catch((err) =>
1146
- console.error('[canvas] Failed to save multi-move:', err)
1147
- )
1518
+ updateCanvas(canvasId, { widgets: next })
1519
+ .catch((err) => console.error('[canvas] Failed to save multi-move:', err))
1148
1520
  )
1149
1521
  return next
1150
1522
  })
@@ -1173,10 +1545,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1173
1545
  return s
1174
1546
  })
1175
1547
  if (changed) {
1548
+ dirtyRef.current = true
1176
1549
  queueWrite(() =>
1177
- updateCanvas(canvasId, { sources: next }).catch((err) =>
1178
- console.error('[canvas] Failed to save multi-move sources:', err)
1179
- )
1550
+ updateCanvas(canvasId, { sources: next })
1551
+ .catch((err) => console.error('[canvas] Failed to save multi-move sources:', err))
1180
1552
  )
1181
1553
  }
1182
1554
  return changed ? next : current
@@ -1192,10 +1564,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1192
1564
  const next = current.some((s) => s?.export === sourceExport)
1193
1565
  ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
1194
1566
  : [...current, { export: sourceExport, position: rounded }]
1567
+ dirtyRef.current = true
1195
1568
  queueWrite(() =>
1196
- updateCanvas(canvasId, { sources: next }).catch((err) =>
1197
- console.error('[canvas] Failed to save source position:', err)
1198
- )
1569
+ updateCanvas(canvasId, { sources: next })
1570
+ .catch((err) => console.error('[canvas] Failed to save source position:', err))
1199
1571
  )
1200
1572
  return next
1201
1573
  })
@@ -1203,15 +1575,16 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1203
1575
  }
1204
1576
 
1205
1577
  undoRedo.snapshot(stateRef.current, 'move', dragId)
1578
+ debouncedSave.cancel()
1206
1579
  setLocalWidgets((prev) => {
1207
1580
  if (!prev) return prev
1208
1581
  const next = prev.map((w) =>
1209
1582
  w.id === dragId ? { ...w, position: rounded } : w
1210
1583
  )
1584
+ dirtyRef.current = true
1211
1585
  queueWrite(() =>
1212
- updateCanvas(canvasId, { widgets: next }).catch((err) =>
1213
- console.error('[canvas] Failed to save widget position:', err)
1214
- )
1586
+ updateCanvas(canvasId, { widgets: next })
1587
+ .catch((err) => console.error('[canvas] Failed to save widget position:', err))
1215
1588
  )
1216
1589
  return next
1217
1590
  })
@@ -1236,20 +1609,21 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1236
1609
  if (!el || loading) return
1237
1610
  const saved = pendingScrollRestore.current
1238
1611
  if (saved) {
1239
- console.log('[viewport] restoring saved viewport — zoom:', saved.zoom, 'scroll:', saved.scrollLeft, saved.scrollTop)
1612
+ if (getFlag('dev-logs')) console.log('[viewport] restoring saved viewport — zoom:', saved.zoom, 'scroll:', saved.scrollLeft, saved.scrollTop)
1240
1613
  // Fresh saved viewport — restore exactly
1241
1614
  if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
1242
1615
  if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
1243
1616
  pendingScrollRestore.current = null
1244
1617
  } else {
1245
- console.log('[viewport] no saved viewport — fitting to objects')
1618
+ if (getFlag('dev-logs')) console.log('[viewport] no saved viewport — fitting to objects')
1246
1619
  // No saved state or stale — zoom-to-fit all objects
1247
1620
  const bounds = computeCanvasBounds(localWidgets, componentEntries)
1248
1621
  if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
1249
1622
  const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
1250
1623
  const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
1251
1624
  const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
1252
- const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
1625
+ const { ZOOM_MIN: zMin, ZOOM_MAX: zMax } = zoomLimits()
1626
+ const fitZoom = Math.min(zMax, Math.max(zMin, Math.round(fitScale * 100)))
1253
1627
  const newScale = fitZoom / 100
1254
1628
  zoomRef.current = fitZoom
1255
1629
  // Imperative DOM update for initial zoom-to-fit — same path as applyZoom
@@ -1326,7 +1700,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1326
1700
  useEffect(() => {
1327
1701
  if (viewportInitName.current !== canvasId) return
1328
1702
  const el = scrollRef.current
1329
- console.log('[viewport] saving — zoom:', zoom, 'scroll:', el?.scrollLeft, el?.scrollTop)
1703
+ if (getFlag('dev-logs')) console.log('[viewport] saving — zoom:', zoom, 'scroll:', el?.scrollLeft, el?.scrollTop)
1330
1704
  // Read current scroll so the zoom entry doesn't zero-out position,
1331
1705
  // but the authoritative scroll save comes from the scroll handler.
1332
1706
  saveViewportState(canvasId, {
@@ -1371,6 +1745,56 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1371
1745
  }
1372
1746
  }, [canvasId, loading])
1373
1747
 
1748
+ // Gather current viewport data from refs (safe for callbacks/timeouts)
1749
+ const getViewportData = useCallback(() => {
1750
+ const el = scrollRef.current
1751
+ if (!el) return null
1752
+ const scale = zoomRef.current / 100
1753
+ const scrollLeft = el.scrollLeft
1754
+ const scrollTop = el.scrollTop
1755
+ const cw = el.clientWidth
1756
+ const ch = el.clientHeight
1757
+ return {
1758
+ centerX: Math.round((scrollLeft + cw / 2) / scale),
1759
+ centerY: Math.round((scrollTop + ch / 2) / scale),
1760
+ zoom: zoomRef.current,
1761
+ topLeftX: Math.round(scrollLeft / scale),
1762
+ topLeftY: Math.round(scrollTop / scale),
1763
+ width: Math.round(cw / scale),
1764
+ height: Math.round(ch / scale),
1765
+ }
1766
+ }, [])
1767
+
1768
+ // Debounced viewport-changed HMR event — sends position/zoom to Vite server
1769
+ // so the selected-widgets bridge can write it to disk for agents.
1770
+ useEffect(() => {
1771
+ if (!import.meta.hot) return
1772
+ const el = scrollRef.current
1773
+ if (!el) return
1774
+
1775
+ const tabId = selectionTabIdRef.current
1776
+
1777
+ function sendViewport() {
1778
+ const viewport = getViewportData()
1779
+ if (viewport) {
1780
+ import.meta.hot.send('storyboard:viewport-changed', { tabId, canvasId, viewport })
1781
+ }
1782
+ }
1783
+
1784
+ const debouncedSend = debounce(sendViewport, 500)
1785
+
1786
+ function handleScroll() { debouncedSend() }
1787
+ el.addEventListener('scroll', handleScroll, { passive: true })
1788
+
1789
+ // Also send on zoom commits (zoom state changes trigger this effect)
1790
+ sendViewport()
1791
+
1792
+ return () => {
1793
+ debouncedSend.cancel()
1794
+ el.removeEventListener('scroll', handleScroll)
1795
+ }
1796
+ }, [canvasId, zoom, loading, getViewportData])
1797
+
1374
1798
  /**
1375
1799
  * Zoom to a new level, anchoring on an optional client-space point.
1376
1800
  * When a cursor position is provided (e.g. from a wheel event), the
@@ -1385,6 +1809,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1385
1809
  function applyZoom(newZoom, clientX, clientY) {
1386
1810
  const el = scrollRef.current
1387
1811
  const zoomEl = zoomElRef.current
1812
+ const { ZOOM_MIN, ZOOM_MAX } = zoomLimits()
1388
1813
  const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
1389
1814
 
1390
1815
  if (!el || !zoomEl) {
@@ -1431,7 +1856,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1431
1856
  if (!zoomEventTimer.current) {
1432
1857
  zoomEventTimer.current = setTimeout(() => {
1433
1858
  zoomEventTimer.current = null
1434
- window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
1859
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
1860
+ bridge.active = true
1861
+ bridge.canvasId = canvasId
1862
+ bridge.zoom = zoomRef.current
1863
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
1435
1864
  document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
1436
1865
  detail: { zoom: zoomRef.current }
1437
1866
  }))
@@ -1441,7 +1870,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1441
1870
 
1442
1871
  // Signal canvas mount/unmount to CoreUIBar
1443
1872
  useEffect(() => {
1444
- window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
1873
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
1874
+ bridge.active = true
1875
+ bridge.canvasId = canvasId
1876
+ bridge.zoom = zoomRef.current
1877
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
1445
1878
  document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
1446
1879
  detail: { canvasId, zoom: zoomRef.current }
1447
1880
  }))
@@ -1461,14 +1894,15 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1461
1894
  }, [canvasId])
1462
1895
 
1463
1896
  // Tell the Vite dev server to suppress full-reloads while this canvas is active.
1464
- // The ?canvas-hmr URL param opts out of the guard for canvas UI development.
1897
+ // Controlled by the "canvas-auto-reload" feature flag (default: false = guard ON).
1898
+ // When the flag is true, the guard is skipped so canvas pages receive HMR updates.
1465
1899
  // Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
1466
1900
  useEffect(() => {
1467
1901
  if (!import.meta.hot) return
1468
- const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
1469
- if (hmrEnabled) return
1902
+ const autoReload = getFlag('canvas-auto-reload')
1903
+ if (autoReload) return
1470
1904
 
1471
- const msg = { active: true, hmrEnabled: false }
1905
+ const msg = { active: true }
1472
1906
  import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
1473
1907
  const interval = setInterval(() => {
1474
1908
  import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
@@ -1476,7 +1910,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1476
1910
 
1477
1911
  return () => {
1478
1912
  clearInterval(interval)
1479
- import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
1913
+ import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false })
1480
1914
  }
1481
1915
  }, [canvasId])
1482
1916
 
@@ -1510,7 +1944,8 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1510
1944
 
1511
1945
  function sendFocus() {
1512
1946
  const { widgetIds, widgets } = getSelectedWidgetData()
1513
- import.meta.hot.send('storyboard:canvas-focused', { tabId, canvasId, widgetIds, widgets })
1947
+ const viewport = getViewportData()
1948
+ import.meta.hot.send('storyboard:canvas-focused', { tabId, canvasId, widgetIds, widgets, viewport })
1514
1949
  }
1515
1950
 
1516
1951
  sendFocus()
@@ -1537,21 +1972,29 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1537
1972
  const tabId = selectionTabIdRef.current
1538
1973
  const timer = setTimeout(() => {
1539
1974
  const { widgetIds, widgets } = getSelectedWidgetData()
1540
- import.meta.hot.send('storyboard:selection-changed', { tabId, canvasId, widgetIds: widgetIds, widgets })
1975
+ const viewport = getViewportData()
1976
+ import.meta.hot.send('storyboard:selection-changed', { tabId, canvasId, widgetIds: widgetIds, widgets, viewport })
1541
1977
  }, 500)
1542
1978
 
1543
1979
  return () => clearTimeout(timer)
1544
1980
  }, [selectedWidgetIds, canvasId, getSelectedWidgetData])
1545
1981
 
1546
1982
  // Add a widget by type — used by CanvasControls and CoreUIBar event
1547
- const addWidget = useCallback(async (type) => {
1983
+ const addWidget = useCallback(async (type, extraProps = {}) => {
1548
1984
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
1985
+ // For terminal/agent, apply config-based dimension defaults over schema defaults
1986
+ if (type === 'terminal' || type === 'agent') {
1987
+ const dims = getTerminalDimensions(extraProps.agentId, { width: defaultProps.width ?? 800, height: defaultProps.height ?? 450 })
1988
+ defaultProps.width = dims.width
1989
+ defaultProps.height = dims.height
1990
+ }
1991
+ const mergedProps = { ...defaultProps, ...extraProps }
1549
1992
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1550
- const pos = centerPositionForWidget(center, type, defaultProps)
1993
+ const pos = centerPositionForWidget(center, type, mergedProps)
1551
1994
  try {
1552
1995
  const result = await addWidgetApi(canvasId, {
1553
1996
  type,
1554
- props: defaultProps,
1997
+ props: mergedProps,
1555
1998
  position: pos,
1556
1999
  })
1557
2000
  if (result.success && result.widget) {
@@ -1585,21 +2028,27 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1585
2028
  }
1586
2029
  }, [canvasId, undoRedo])
1587
2030
 
1588
- // Listen for CoreUIBar add-widget events
2031
+ // Listen for CoreUIBar add-widget and update-widget events
1589
2032
  useEffect(() => {
1590
2033
  function handleAddWidget(e) {
1591
- addWidget(e.detail.type)
2034
+ addWidget(e.detail.type, e.detail.props)
1592
2035
  }
1593
2036
  function handleAddStoryWidget(e) {
1594
2037
  addStoryWidget(e.detail.storyId)
1595
2038
  }
2039
+ function handleUpdateWidget(e) {
2040
+ const { widgetId, updates } = e.detail || {}
2041
+ if (widgetId && updates) handleWidgetUpdate(widgetId, updates)
2042
+ }
1596
2043
  document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
1597
2044
  document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
2045
+ document.addEventListener('storyboard:canvas:update-widget', handleUpdateWidget)
1598
2046
  return () => {
1599
2047
  document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
1600
2048
  document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
2049
+ document.removeEventListener('storyboard:canvas:update-widget', handleUpdateWidget)
1601
2050
  }
1602
- }, [addWidget, addStoryWidget])
2051
+ }, [addWidget, addStoryWidget, handleWidgetUpdate])
1603
2052
 
1604
2053
  // Listen for zoom changes from CoreUIBar
1605
2054
  useEffect(() => {
@@ -1679,7 +2128,8 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1679
2128
 
1680
2129
  // Find the zoom level that fits the bounding box in the viewport
1681
2130
  const fitScale = Math.min(viewW / boxW, viewH / boxH)
1682
- const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
2131
+ const { ZOOM_MIN: zMin, ZOOM_MAX: zMax } = zoomLimits()
2132
+ const fitZoom = Math.min(zMax, Math.max(zMin, Math.round(fitScale * 100)))
1683
2133
  const newScale = fitZoom / 100
1684
2134
 
1685
2135
  // Imperative DOM update — same path as applyZoom
@@ -1722,12 +2172,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1722
2172
 
1723
2173
  // Broadcast zoom level to CoreUIBar whenever it changes
1724
2174
  useEffect(() => {
1725
- window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom }
2175
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
2176
+ bridge.active = true
2177
+ bridge.canvasId = canvasId
2178
+ bridge.zoom = zoom
2179
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
1726
2180
  document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
1727
2181
  detail: { zoom }
1728
2182
  }))
1729
2183
  }, [canvasId, zoom])
1730
2184
 
2185
+ // Keep bridge in sync with widgets/connectors for expand features.
2186
+ // Child widgets now use props directly for split-screen gating, but
2187
+ // FigmaEmbed/PrototypeEmbed/etc. still read this bridge at expand time.
2188
+ useMemo(() => {
2189
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
2190
+ bridge.widgets = localWidgets
2191
+ bridge.connectors = localConnectors
2192
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
2193
+ }, [localWidgets, localConnectors])
2194
+
1731
2195
  // Delete selected widget on Delete/Backspace key
1732
2196
  useEffect(() => {
1733
2197
  function handleSelectStart(e) {
@@ -1765,6 +2229,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1765
2229
  // Multi-delete — snapshot once, remove all, persist via updateCanvas
1766
2230
  undoRedo.snapshot(stateRef.current, 'multi-remove')
1767
2231
  debouncedSave.cancel()
2232
+ dirtyRef.current = true
1768
2233
  setLocalWidgets((prev) => {
1769
2234
  if (!prev) return prev
1770
2235
  const next = prev.filter(w => !selectedWidgetIds.has(w.id))
@@ -1857,6 +2322,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1857
2322
  undoRedo.snapshot(stateRef.current, 'add')
1858
2323
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1859
2324
  setSelectedWidgetIds(new Set([result.widget.id]))
2325
+ navigator.clipboard?.writeText(result.widget.id).catch(() => {})
1860
2326
  }
1861
2327
  return true
1862
2328
  } catch (err) {
@@ -1948,9 +2414,18 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1948
2414
  for (const w of sourceWidgets) {
1949
2415
  const relX = (w.position?.x ?? 0) - minX
1950
2416
  const relY = (w.position?.y ?? 0) - minY
2417
+ const pasteProps = { ...w.props }
2418
+ if (w.type === 'terminal' || w.type === 'agent') delete pasteProps.prettyName
2419
+ // Image widgets: duplicate the asset so the paste owns its own copy
2420
+ if (w.type === 'image' && pasteProps.src) {
2421
+ try {
2422
+ const dupResult = await duplicateImage(pasteProps.src)
2423
+ if (dupResult.success) pasteProps.src = dupResult.filename
2424
+ } catch { /* use original src as fallback */ }
2425
+ }
1951
2426
  const result = await addWidgetApi(canvasId, {
1952
2427
  type: w.type,
1953
- props: { ...w.props },
2428
+ props: pasteProps,
1954
2429
  position: { x: baseX + relX, y: baseY + relY },
1955
2430
  })
1956
2431
  if (result.success && result.widget) {
@@ -1970,11 +2445,34 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1970
2445
  }
1971
2446
 
1972
2447
  e.preventDefault()
2448
+ await pasteTextAsWidget(text, pasteCtx)
2449
+ }
2450
+
2451
+ // Shared helper: resolve pasted text into a widget and add it to the canvas.
2452
+ // Used by both native paste and the programmatic paste-url event.
2453
+ async function pasteTextAsWidget(text, pasteCtx) {
1973
2454
  const resolved = resolvePaste(text, pasteCtx, getPasteRules())
1974
2455
  if (!resolved) return
1975
- const { type } = resolved
2456
+ let { type } = resolved
1976
2457
  let props = resolved.props
1977
2458
 
2459
+ // Component/story URLs → story widget (instead of prototype embed)
2460
+ if (type === 'prototype' && props?.src) {
2461
+ const srcPath = props.src.replace(/[?#].*$/, '').replace(/\/+$/, '')
2462
+ const storyId = storyRouteIndex.get(srcPath)
2463
+ if (storyId) {
2464
+ type = 'story'
2465
+ const parsed = pasteCtx.parseUrl(text)
2466
+ const searchParams = new URLSearchParams(parsed?.search || '')
2467
+ props = {
2468
+ storyId,
2469
+ exportName: searchParams.get('export') || '',
2470
+ width: 600,
2471
+ height: 400,
2472
+ }
2473
+ }
2474
+ }
2475
+
1978
2476
  if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
1979
2477
  const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
1980
2478
  if (githubUpdates) props = { ...props, ...githubUpdates }
@@ -1998,8 +2496,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1998
2496
  }
1999
2497
  }
2000
2498
 
2499
+ // Listen for programmatic paste-url events from the command palette
2500
+ function handlePasteUrl(e) {
2501
+ const text = e.detail?.url?.trim()
2502
+ if (!text) return
2503
+ pasteTextAsWidget(text, pasteCtx)
2504
+ }
2505
+
2001
2506
  document.addEventListener('paste', handlePaste)
2002
- return () => document.removeEventListener('paste', handlePaste)
2507
+ document.addEventListener('storyboard:canvas:paste-url', handlePasteUrl)
2508
+ return () => {
2509
+ document.removeEventListener('paste', handlePaste)
2510
+ document.removeEventListener('storyboard:canvas:paste-url', handlePasteUrl)
2511
+ }
2003
2512
  // eslint-disable-next-line react-hooks/exhaustive-deps
2004
2513
  }, [canvasId, undoRedo, localWidgets])
2005
2514
 
@@ -2073,13 +2582,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2073
2582
  if (!previous) return
2074
2583
  debouncedSave.cancel()
2075
2584
  debouncedSourceSave.cancel()
2585
+ dirtyRef.current = true
2076
2586
  setLocalWidgets(previous.widgets)
2077
2587
  setLocalSources(previous.sources)
2078
2588
  setLocalConnectors(previous.connectors ?? [])
2079
2589
  queueWrite(() =>
2080
- updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors }).catch((err) =>
2081
- console.error('[canvas] Failed to persist undo:', err)
2082
- )
2590
+ updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
2591
+ .catch((err) => console.error('[canvas] Failed to persist undo:', err))
2083
2592
  )
2084
2593
  }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
2085
2594
 
@@ -2088,22 +2597,24 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2088
2597
  if (!next) return
2089
2598
  debouncedSave.cancel()
2090
2599
  debouncedSourceSave.cancel()
2600
+ dirtyRef.current = true
2091
2601
  setLocalWidgets(next.widgets)
2092
2602
  setLocalSources(next.sources)
2093
2603
  setLocalConnectors(next.connectors ?? [])
2094
2604
  queueWrite(() =>
2095
- updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors }).catch((err) =>
2096
- console.error('[canvas] Failed to persist redo:', err)
2097
- )
2605
+ updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
2606
+ .catch((err) => console.error('[canvas] Failed to persist redo:', err))
2098
2607
  )
2099
2608
  }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
2100
2609
 
2101
- // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
2610
+ // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z / Cmd+D / Cmd+A)
2102
2611
  useEffect(() => {
2103
2612
  if (!import.meta.hot) return
2104
2613
  function handleKeyDown(e) {
2105
2614
  const tag = e.target.tagName
2106
2615
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
2616
+ // Don't intercept shortcuts when the command palette is open
2617
+ if (e.target.closest?.('[cmdk-root]')) return
2107
2618
  const mod = e.metaKey || e.ctrlKey
2108
2619
  if (mod && e.key === 'z' && !e.shiftKey) {
2109
2620
  e.preventDefault()
@@ -2113,10 +2624,21 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2113
2624
  e.preventDefault()
2114
2625
  handleRedo()
2115
2626
  }
2627
+ if (mod && e.key.toLowerCase() === 'd' && e.shiftKey) {
2628
+ e.preventDefault()
2629
+ handleDuplicateWithConnectors()
2630
+ } else if (mod && e.key.toLowerCase() === 'd' && !e.shiftKey) {
2631
+ e.preventDefault()
2632
+ handleDuplicateSelected()
2633
+ }
2634
+ if (mod && e.key === 'a') {
2635
+ e.preventDefault()
2636
+ handleSelectAll()
2637
+ }
2116
2638
  }
2117
2639
  document.addEventListener('keydown', handleKeyDown)
2118
2640
  return () => document.removeEventListener('keydown', handleKeyDown)
2119
- }, [handleUndo, handleRedo])
2641
+ }, [handleUndo, handleRedo, handleDuplicateSelected, handleDuplicateWithConnectors, handleSelectAll])
2120
2642
 
2121
2643
  // Listen for undo/redo from CoreUIBar (Svelte toolbar)
2122
2644
  useEffect(() => {
@@ -2286,6 +2808,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2286
2808
  zoomRef: zoomRef,
2287
2809
  setSelectedWidgetIds,
2288
2810
  widgets: localWidgets,
2811
+ connectors: localConnectors,
2289
2812
  componentEntries,
2290
2813
  fallbackSizes: WIDGET_FALLBACK_SIZES,
2291
2814
  spaceHeld,
@@ -2343,6 +2866,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2343
2866
  id={`jsx-${exportName}`}
2344
2867
  data-tc-x={sourcePosition.x}
2345
2868
  data-tc-y={sourcePosition.y}
2869
+ data-widget-raised={selectedWidgetIds.has(`jsx-${exportName}`) || undefined}
2346
2870
  {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
2347
2871
  {...canvasPrimerAttrs}
2348
2872
  style={canvasThemeVars}
@@ -2379,38 +2903,44 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2379
2903
  }
2380
2904
 
2381
2905
  // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
2382
- // Sort so selected widgets render last (visually on top via DOM order)
2383
- const sortedWidgets = (localWidgets ?? []).slice().sort((a, b) => {
2384
- const aSelected = selectedWidgetIds.has(a.id) ? 1 : 0
2385
- const bSelected = selectedWidgetIds.has(b.id) ? 1 : 0
2386
- return aSelected - bSelected
2387
- })
2388
- for (const widget of sortedWidgets) {
2389
- if (!isLocalDev && widget.type === 'terminal') continue
2906
+ // Stable DOM order visual stacking is controlled by z-index on the
2907
+ // wrapper div (data-widget-raised), NOT by re-sorting the array.
2908
+ // Re-sorting caused iframe widgets (stories, embeds) to remount and
2909
+ // reload every time selection changed, because moving an iframe node
2910
+ // in the DOM destroys its browsing context.
2911
+ for (const widget of (localWidgets ?? [])) {
2912
+ // In production, render terminal widgets as read-only instead of hiding them
2913
+ const effectiveWidget = (!isLocalDev && (widget.type === 'terminal' || widget.type === 'agent'))
2914
+ ? { ...widget, type: 'terminal-read' }
2915
+ : widget
2390
2916
  allChildren.push(
2391
2917
  <div
2392
- key={widget.id}
2393
- id={widget.id}
2394
- data-tc-x={widget?.position?.x ?? 0}
2395
- data-tc-y={widget?.position?.y ?? 0}
2918
+ key={effectiveWidget.id}
2919
+ id={effectiveWidget.id}
2920
+ data-tc-x={effectiveWidget?.position?.x ?? 0}
2921
+ data-tc-y={effectiveWidget?.position?.y ?? 0}
2922
+ data-widget-raised={selectedWidgetIds.has(widget.id) || undefined}
2396
2923
  {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
2397
2924
  {...canvasPrimerAttrs}
2398
2925
  style={canvasThemeVars}
2399
2926
  onClick={isLocalDev ? (e) => {
2400
2927
  e.stopPropagation()
2401
2928
  if (!e.target.closest('.tc-drag-handle')) {
2402
- handleWidgetSelect(widget.id, e.shiftKey)
2929
+ handleWidgetSelect(effectiveWidget.id, e.shiftKey)
2403
2930
  }
2404
2931
  } : undefined}
2405
2932
  >
2406
2933
  <ChromeWrappedWidget
2407
- widget={widget}
2934
+ widget={effectiveWidget}
2408
2935
  selected={selectedWidgetIds.has(widget.id)}
2409
2936
  multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
2937
+ connectorCount={localConnectors.filter((c) => c.start?.widgetId === widget.id || c.end?.widgetId === widget.id)}
2938
+ allWidgets={localWidgets}
2410
2939
  onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
2411
2940
  onDeselect={handleDeselectAll}
2412
2941
  onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
2413
2942
  onCopy={isLocalDev ? handleWidgetCopy : undefined}
2943
+ onCopyWithConnectors={isLocalDev ? handleWidgetCopyWithConnectors : undefined}
2414
2944
  onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
2415
2945
  onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
2416
2946
  canRefreshGitHub={isLocalDev}
@@ -2423,19 +2953,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2423
2953
 
2424
2954
  const scale = zoom / 100
2425
2955
 
2426
- const terminalWidgetIds = !isLocalDev
2427
- ? new Set((localWidgets ?? []).filter(w => w.type === 'terminal').map(w => w.id))
2428
- : null
2429
-
2430
- const filteredConnectors = terminalWidgetIds?.size
2431
- ? localConnectors.filter(c => !terminalWidgetIds.has(c.startWidgetId) && !terminalWidgetIds.has(c.endWidgetId))
2432
- : localConnectors
2956
+ const filteredConnectors = localConnectors
2433
2957
 
2434
2958
  return (
2435
2959
  <>
2436
2960
  <div className={styles.canvasTitle}>
2437
2961
  <a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
2438
- <Icon name="iconoir/key-command" size={16} color="#fff" />
2962
+ <Icon name="home" size={16} color="#fff" />
2439
2963
  </a>
2440
2964
  <CanvasTitleEditable
2441
2965
  canvasId={canvasId}
@@ -2444,9 +2968,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2444
2968
  isLocalDev={isLocalDev}
2445
2969
  />
2446
2970
  <PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
2447
- {isLocalDev && (
2448
- <span className={styles.localEditingLabel}>Local editing</span>
2449
- )}
2450
2971
  </div>
2451
2972
  <div
2452
2973
  ref={scrollRef}
@@ -2477,8 +2998,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2477
2998
  <ConnectorLayer
2478
2999
  connectors={filteredConnectors}
2479
3000
  widgets={localWidgets ?? []}
3001
+ selectedWidgetIds={selectedWidgetIds}
2480
3002
  onRemove={isLocalDev ? handleConnectorRemove : undefined}
2481
- onEndpointDrag={isLocalDev ? handleEndpointDrag : undefined}
3003
+ onEndpointDrag={undefined}
2482
3004
  dragPreview={connectorDrag}
2483
3005
  hidden={widgetDragging}
2484
3006
  />