@dfosco/storyboard-react 4.2.0-beta.4 → 4.2.1

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