@dfosco/storyboard-react 4.2.0-beta.0 → 4.2.0-beta.17

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 (48) hide show
  1. package/package.json +5 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  3. package/src/BranchBar/BranchBar.jsx +17 -5
  4. package/src/BranchBar/BranchBar.module.css +11 -2
  5. package/src/CommandPalette/CommandPalette.jsx +267 -164
  6. package/src/CommandPalette/command-palette.css +130 -78
  7. package/src/Icon.jsx +112 -48
  8. package/src/Viewfinder.jsx +511 -61
  9. package/src/Viewfinder.module.css +414 -2
  10. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  11. package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
  12. package/src/canvas/CanvasPage.jsx +157 -174
  13. package/src/canvas/CanvasPage.module.css +0 -15
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
  15. package/src/canvas/ConnectorLayer.jsx +5 -5
  16. package/src/canvas/PageSelector.test.jsx +15 -6
  17. package/src/canvas/useCanvas.js +1 -1
  18. package/src/canvas/widgets/ActionWidget.jsx +200 -0
  19. package/src/canvas/widgets/ActionWidget.module.css +122 -0
  20. package/src/canvas/widgets/FigmaEmbed.jsx +97 -29
  21. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  22. package/src/canvas/widgets/ImageWidget.jsx +1 -1
  23. package/src/canvas/widgets/LinkPreview.jsx +64 -5
  24. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  25. package/src/canvas/widgets/MarkdownBlock.jsx +39 -17
  26. package/src/canvas/widgets/MarkdownBlock.module.css +123 -0
  27. package/src/canvas/widgets/PrototypeEmbed.jsx +183 -20
  28. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  29. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  30. package/src/canvas/widgets/SplitExpandModal.jsx +234 -0
  31. package/src/canvas/widgets/SplitExpandModal.module.css +335 -0
  32. package/src/canvas/widgets/SplitScreenTopBar.jsx +30 -0
  33. package/src/canvas/widgets/SplitScreenTopBar.module.css +58 -0
  34. package/src/canvas/widgets/StoryWidget.jsx +7 -4
  35. package/src/canvas/widgets/TerminalReadWidget.jsx +140 -0
  36. package/src/canvas/widgets/TerminalReadWidget.module.css +92 -0
  37. package/src/canvas/widgets/TerminalWidget.jsx +299 -49
  38. package/src/canvas/widgets/TerminalWidget.module.css +155 -1
  39. package/src/canvas/widgets/WidgetChrome.jsx +19 -14
  40. package/src/canvas/widgets/WidgetChrome.module.css +10 -0
  41. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  42. package/src/canvas/widgets/expandUtils.js +188 -0
  43. package/src/canvas/widgets/index.js +5 -0
  44. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  45. package/src/canvas/widgets/widgetConfig.js +19 -1
  46. package/src/hooks/useConfig.js +14 -0
  47. package/src/index.js +4 -0
  48. package/src/vite/data-plugin.js +264 -14
@@ -6,11 +6,12 @@ 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 } from './widgets/widgetConfig.js'
10
10
  import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
11
11
  import { getPasteRules } from '@dfosco/storyboard-core'
12
12
  import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
13
13
  import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
14
+ import { findConnectedSplitTarget } from './widgets/expandUtils.js'
14
15
  import WidgetChrome from './widgets/WidgetChrome.jsx'
15
16
  import ComponentWidget from './widgets/ComponentWidget.jsx'
16
17
  import useUndoRedo from './useUndoRedo.js'
@@ -303,6 +304,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
303
304
  widget,
304
305
  selected,
305
306
  multiSelected,
307
+ connectorCount,
306
308
  onSelect,
307
309
  onDeselect,
308
310
  onUpdate,
@@ -319,7 +321,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
319
321
  // Dynamically adjust features based on widget state
320
322
  const features = useMemo(() => {
321
323
  const isGitHub = !!widget.props?.github
322
- return rawFeatures.map((f) => {
324
+ const adjusted = rawFeatures.map((f) => {
323
325
  // Toggle collapse label and hide when content is short (no github = no collapse)
324
326
  if (f.action === 'toggle-collapse') {
325
327
  if (widget.type === 'link-preview' && !isGitHub) return null
@@ -333,7 +335,28 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
333
335
  if (f.action === 'refresh-github' && !isGitHub) return null
334
336
  return f
335
337
  }).filter(Boolean)
336
- }, [rawFeatures, widget.props?.github, widget.props?.collapsed])
338
+
339
+ // Add dynamic "Split Screen" action when a connected split target exists
340
+ if (isExpandable(widget.type)) {
341
+ const hasConnected = Boolean(findConnectedSplitTarget(widget.id))
342
+ if (hasConnected) {
343
+ // Insert before the first menu-only feature
344
+ const insertIdx = adjusted.findIndex((f) => f.menu)
345
+ const splitFeature = {
346
+ id: 'split-screen',
347
+ type: 'action',
348
+ action: 'split-screen',
349
+ label: 'Split Screen',
350
+ icon: 'columns',
351
+ prod: true,
352
+ }
353
+ if (insertIdx >= 0) adjusted.splice(insertIdx, 0, splitFeature)
354
+ else adjusted.push(splitFeature)
355
+ }
356
+ }
357
+
358
+ return adjusted
359
+ }, [rawFeatures, widget.props?.github, widget.props?.collapsed, widget.type, widget.id, connectorCount])
337
360
 
338
361
  const handleAction = useCallback((actionId) => {
339
362
  if (actionId === 'delete') {
@@ -398,6 +421,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
398
421
  prev.widget === next.widget &&
399
422
  prev.selected === next.selected &&
400
423
  prev.multiSelected === next.multiSelected &&
424
+ prev.connectorCount === next.connectorCount &&
401
425
  prev.readOnly === next.readOnly &&
402
426
  prev.onSelect === next.onSelect &&
403
427
  prev.onDeselect === next.onDeselect &&
@@ -571,6 +595,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
571
595
  stateRef.current = { widgets: localWidgets, sources: localSources, connectors: localConnectors }
572
596
  }, [localWidgets, localSources, localConnectors])
573
597
 
598
+ // Dirty flag — true while optimistic edits haven't been persisted yet.
599
+ // Prevents HMR echoes from overwriting in-flight local state.
600
+ const dirtyRef = useRef(false)
601
+
574
602
  // Serialized write queue — ensures JSONL events land in the right order
575
603
  const writeQueueRef = useRef(Promise.resolve())
576
604
  function queueWrite(fn) {
@@ -665,9 +693,16 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
665
693
  const isCanvasSwitch = trackedCanvas && canvas && trackedCanvas._route !== canvas._route
666
694
  console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
667
695
  setTrackedCanvas(canvas)
668
- setLocalWidgets(canvas?.widgets ?? null)
669
- setLocalConnectors(canvas?.connectors ?? [])
670
- setLocalSources(canvas?.sources ?? [])
696
+
697
+ // Skip replacing local state with server data when optimistic edits are
698
+ // pending — the local state is more recent. The next save will persist it
699
+ // and the subsequent server push (after dirty clears) will reconcile.
700
+ if (!dirtyRef.current || isCanvasSwitch) {
701
+ setLocalWidgets(canvas?.widgets ?? null)
702
+ setLocalConnectors(canvas?.connectors ?? [])
703
+ setLocalSources(canvas?.sources ?? [])
704
+ }
705
+
671
706
  setSnapEnabled(canvas?.snapToGrid ?? false)
672
707
  setSnapGridSize(canvas?.gridSize || 40)
673
708
  undoRedo.reset()
@@ -683,11 +718,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
683
718
  }
684
719
  }
685
720
 
686
- // Debounced save to server
721
+ // Debounced save to server — routed through queueWrite to serialize
722
+ // with deletes and other writes, preventing stale data from overwriting.
687
723
  const debouncedSave = useRef(
688
724
  debounce((canvasId, widgets) => {
689
- updateCanvas(canvasId, { widgets }).catch((err) =>
690
- console.error('[canvas] Failed to save:', err)
725
+ queueWrite(() =>
726
+ updateCanvas(canvasId, { widgets })
727
+ .catch((err) => console.error('[canvas] Failed to save:', err))
728
+ .finally(() => { dirtyRef.current = false })
691
729
  )
692
730
  }, 2000)
693
731
  ).current
@@ -705,12 +743,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
705
743
  const next = prev.map((w) =>
706
744
  w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
707
745
  )
746
+ dirtyRef.current = true
708
747
  debouncedSave(canvasId, next)
709
748
  return next
710
749
  })
711
750
  }, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
712
751
 
713
752
  const handleWidgetRemove = useCallback((widgetId) => {
753
+ // Cancel any pending debounced save — it may contain stale data
754
+ // that includes the widget we're about to delete
755
+ debouncedSave.cancel()
756
+
714
757
  undoRedo.snapshot(stateRef.current, 'remove', widgetId)
715
758
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
716
759
  // Cascade: remove connectors referencing this widget
@@ -726,12 +769,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
726
769
  }
727
770
  return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
728
771
  })
772
+ dirtyRef.current = true
729
773
  queueWrite(() =>
730
- removeWidgetApi(canvasId, widgetId).catch((err) =>
731
- console.error('[canvas] Failed to remove widget:', err)
732
- )
774
+ removeWidgetApi(canvasId, widgetId)
775
+ .catch((err) => console.error('[canvas] Failed to remove widget:', err))
776
+ .finally(() => { dirtyRef.current = false })
733
777
  )
734
- }, [canvasId, undoRedo])
778
+ }, [canvasId, undoRedo, debouncedSave])
735
779
 
736
780
  const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
737
781
  try {
@@ -802,21 +846,52 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
802
846
  y: (scrollEl.scrollTop + clientY - rect.top) / scale,
803
847
  })
804
848
 
805
- // Find nearest anchor on any other widget within snap distance
806
- const SNAP_DIST = 40
849
+ // Find nearest anchor on any other widget within a rectangular snap zone.
850
+ // Each anchor has a 30px-wide strip (15px each side) extending from the widget edge.
851
+ const SNAP_EXTEND = 15
852
+ const SNAP_DEPTH = 40
853
+ const SNAP_CROSS = 20 // perpendicular expansion so you can approach from any direction
807
854
  const sourceType = startWidget.type
808
855
  const findNearestAnchor = (canvasPt) => {
809
856
  const currentWidgets = stateRef.current.widgets ?? []
810
857
  let best = null
811
- let bestDist = SNAP_DIST
858
+ let bestDist = Infinity
812
859
  for (const w of currentWidgets) {
813
860
  if (w.id === widgetId) continue
814
- // Check if this widget type accepts connections from the source type
815
861
  if (!canAcceptConnection(w.type, sourceType)) continue
862
+
863
+ let ww, wh
864
+ const el = document.getElementById(w.id)
865
+ if (el) {
866
+ const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
867
+ if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
868
+ }
869
+ if (!ww) ww = w.props?.width ?? w.bounds?.width ?? 270
870
+ if (!wh) wh = w.props?.height ?? w.bounds?.height ?? 170
871
+ const wx = w.position?.x ?? 0
872
+ const wy = w.position?.y ?? 0
873
+
816
874
  for (const anch of ['top', 'bottom', 'left', 'right']) {
817
- // Skip unavailable or disabled anchors
818
875
  const anchorState = getAnchorState(w.type, anch)
819
876
  if (anchorState !== 'available') continue
877
+
878
+ // Build a rectangular hit zone for this anchor
879
+ let inZone = false
880
+ if (anch === 'top') {
881
+ inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
882
+ canvasPt.y >= wy - SNAP_DEPTH && canvasPt.y <= wy + SNAP_EXTEND
883
+ } else if (anch === 'bottom') {
884
+ inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
885
+ canvasPt.y >= wy + wh - SNAP_EXTEND && canvasPt.y <= wy + wh + SNAP_DEPTH
886
+ } else if (anch === 'left') {
887
+ inZone = canvasPt.x >= wx - SNAP_DEPTH && canvasPt.x <= wx + SNAP_EXTEND &&
888
+ canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
889
+ } else if (anch === 'right') {
890
+ inZone = canvasPt.x >= wx + ww - SNAP_EXTEND && canvasPt.x <= wx + ww + SNAP_DEPTH &&
891
+ canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
892
+ }
893
+ if (!inZone) continue
894
+
820
895
  const pt = computeAnchorPt(w, anch)
821
896
  const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
822
897
  if (dist < bestDist) {
@@ -872,119 +947,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
872
947
  document.addEventListener('pointerup', handlePointerUp)
873
948
  }, [handleConnectorAdd])
874
949
 
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
889
-
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 }
896
- }
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 }
907
- }
908
- }
909
-
910
- const fixedPt = computeAnchorPtLocal(fixedWidget, fixedSide.anchor)
911
- const fixedWidgetId = fixedSide.widgetId
912
-
913
- const toCanvasPoint = (clientX, clientY) => ({
914
- x: (scrollEl.scrollLeft + clientX - rect.left) / scale,
915
- y: (scrollEl.scrollTop + clientY - rect.top) / scale,
916
- })
917
-
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 }
935
- }
936
- }
937
- }
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
-
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)
961
- }
962
-
963
- const handlePointerUp = (upE) => {
964
- document.removeEventListener('pointermove', handlePointerMove)
965
- document.removeEventListener('pointerup', handlePointerUp)
966
-
967
- const pt = toCanvasPoint(upE.clientX, upE.clientY)
968
- const nearSnap = findNearestAnchorLocal(pt)
969
-
970
- // Always remove the old connector
971
- handleConnectorRemove(connector.id)
972
-
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,
980
- })
981
- }
982
- setConnectorDrag(null)
983
- }
984
-
985
- document.addEventListener('pointermove', handlePointerMove)
986
- document.addEventListener('pointerup', handlePointerUp)
987
- }, [handleConnectorAdd, handleConnectorRemove])
950
+ // Endpoint drag removed dragging from a filled anchor now always
951
+ // creates a new connection via handleConnectorDragStart instead of
952
+ // repositioning the existing one.
988
953
 
989
954
  const handleWidgetCopy = useCallback(async (widget) => {
990
955
  // Find the next free offset — check how many copies already exist at +n*40
@@ -999,10 +964,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
999
964
  }
1000
965
  const position = { x: baseX + n * 40, y: baseY + n * 40 }
1001
966
  try {
967
+ const copyProps = { ...widget.props }
968
+ // Terminal widgets must get unique names — strip prettyName so the server generates a fresh one
969
+ if (widget.type === 'terminal' || widget.type === 'agent') delete copyProps.prettyName
970
+
1002
971
  undoRedo.snapshot(stateRef.current, 'add')
1003
972
  const result = await addWidgetApi(canvasId, {
1004
973
  type: widget.type,
1005
- props: { ...widget.props },
974
+ props: copyProps,
1006
975
  position,
1007
976
  })
1008
977
  if (result.success && result.widget) {
@@ -1141,10 +1110,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1141
1110
  }
1142
1111
  return w
1143
1112
  })
1113
+ dirtyRef.current = true
1144
1114
  queueWrite(() =>
1145
- updateCanvas(canvasId, { widgets: next }).catch((err) =>
1146
- console.error('[canvas] Failed to save multi-move:', err)
1147
- )
1115
+ updateCanvas(canvasId, { widgets: next })
1116
+ .catch((err) => console.error('[canvas] Failed to save multi-move:', err))
1117
+ .finally(() => { dirtyRef.current = false })
1148
1118
  )
1149
1119
  return next
1150
1120
  })
@@ -1173,10 +1143,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1173
1143
  return s
1174
1144
  })
1175
1145
  if (changed) {
1146
+ dirtyRef.current = true
1176
1147
  queueWrite(() =>
1177
- updateCanvas(canvasId, { sources: next }).catch((err) =>
1178
- console.error('[canvas] Failed to save multi-move sources:', err)
1179
- )
1148
+ updateCanvas(canvasId, { sources: next })
1149
+ .catch((err) => console.error('[canvas] Failed to save multi-move sources:', err))
1150
+ .finally(() => { dirtyRef.current = false })
1180
1151
  )
1181
1152
  }
1182
1153
  return changed ? next : current
@@ -1192,10 +1163,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1192
1163
  const next = current.some((s) => s?.export === sourceExport)
1193
1164
  ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
1194
1165
  : [...current, { export: sourceExport, position: rounded }]
1166
+ dirtyRef.current = true
1195
1167
  queueWrite(() =>
1196
- updateCanvas(canvasId, { sources: next }).catch((err) =>
1197
- console.error('[canvas] Failed to save source position:', err)
1198
- )
1168
+ updateCanvas(canvasId, { sources: next })
1169
+ .catch((err) => console.error('[canvas] Failed to save source position:', err))
1170
+ .finally(() => { dirtyRef.current = false })
1199
1171
  )
1200
1172
  return next
1201
1173
  })
@@ -1203,15 +1175,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1203
1175
  }
1204
1176
 
1205
1177
  undoRedo.snapshot(stateRef.current, 'move', dragId)
1178
+ debouncedSave.cancel()
1206
1179
  setLocalWidgets((prev) => {
1207
1180
  if (!prev) return prev
1208
1181
  const next = prev.map((w) =>
1209
1182
  w.id === dragId ? { ...w, position: rounded } : w
1210
1183
  )
1184
+ dirtyRef.current = true
1211
1185
  queueWrite(() =>
1212
- updateCanvas(canvasId, { widgets: next }).catch((err) =>
1213
- console.error('[canvas] Failed to save widget position:', err)
1214
- )
1186
+ updateCanvas(canvasId, { widgets: next })
1187
+ .catch((err) => console.error('[canvas] Failed to save widget position:', err))
1188
+ .finally(() => { dirtyRef.current = false })
1215
1189
  )
1216
1190
  return next
1217
1191
  })
@@ -1544,14 +1518,15 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1544
1518
  }, [selectedWidgetIds, canvasId, getSelectedWidgetData])
1545
1519
 
1546
1520
  // Add a widget by type — used by CanvasControls and CoreUIBar event
1547
- const addWidget = useCallback(async (type) => {
1521
+ const addWidget = useCallback(async (type, extraProps = {}) => {
1548
1522
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
1523
+ const mergedProps = { ...defaultProps, ...extraProps }
1549
1524
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1550
- const pos = centerPositionForWidget(center, type, defaultProps)
1525
+ const pos = centerPositionForWidget(center, type, mergedProps)
1551
1526
  try {
1552
1527
  const result = await addWidgetApi(canvasId, {
1553
1528
  type,
1554
- props: defaultProps,
1529
+ props: mergedProps,
1555
1530
  position: pos,
1556
1531
  })
1557
1532
  if (result.success && result.widget) {
@@ -1588,7 +1563,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1588
1563
  // Listen for CoreUIBar add-widget events
1589
1564
  useEffect(() => {
1590
1565
  function handleAddWidget(e) {
1591
- addWidget(e.detail.type)
1566
+ addWidget(e.detail.type, e.detail.props)
1592
1567
  }
1593
1568
  function handleAddStoryWidget(e) {
1594
1569
  addStoryWidget(e.detail.storyId)
@@ -1728,6 +1703,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1728
1703
  }))
1729
1704
  }, [canvasId, zoom])
1730
1705
 
1706
+ // Keep bridge in sync with widgets/connectors for expand features
1707
+ useEffect(() => {
1708
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
1709
+ bridge.widgets = localWidgets
1710
+ bridge.connectors = localConnectors
1711
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
1712
+ }, [localWidgets, localConnectors])
1713
+
1731
1714
  // Delete selected widget on Delete/Backspace key
1732
1715
  useEffect(() => {
1733
1716
  function handleSelectStart(e) {
@@ -1857,6 +1840,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1857
1840
  undoRedo.snapshot(stateRef.current, 'add')
1858
1841
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1859
1842
  setSelectedWidgetIds(new Set([result.widget.id]))
1843
+ navigator.clipboard?.writeText(result.widget.id).catch(() => {})
1860
1844
  }
1861
1845
  return true
1862
1846
  } catch (err) {
@@ -1948,9 +1932,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1948
1932
  for (const w of sourceWidgets) {
1949
1933
  const relX = (w.position?.x ?? 0) - minX
1950
1934
  const relY = (w.position?.y ?? 0) - minY
1935
+ const pasteProps = { ...w.props }
1936
+ if (w.type === 'terminal' || w.type === 'agent') delete pasteProps.prettyName
1951
1937
  const result = await addWidgetApi(canvasId, {
1952
1938
  type: w.type,
1953
- props: { ...w.props },
1939
+ props: pasteProps,
1954
1940
  position: { x: baseX + relX, y: baseY + relY },
1955
1941
  })
1956
1942
  if (result.success && result.widget) {
@@ -2073,13 +2059,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2073
2059
  if (!previous) return
2074
2060
  debouncedSave.cancel()
2075
2061
  debouncedSourceSave.cancel()
2062
+ dirtyRef.current = true
2076
2063
  setLocalWidgets(previous.widgets)
2077
2064
  setLocalSources(previous.sources)
2078
2065
  setLocalConnectors(previous.connectors ?? [])
2079
2066
  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
- )
2067
+ updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
2068
+ .catch((err) => console.error('[canvas] Failed to persist undo:', err))
2069
+ .finally(() => { dirtyRef.current = false })
2083
2070
  )
2084
2071
  }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
2085
2072
 
@@ -2088,13 +2075,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2088
2075
  if (!next) return
2089
2076
  debouncedSave.cancel()
2090
2077
  debouncedSourceSave.cancel()
2078
+ dirtyRef.current = true
2091
2079
  setLocalWidgets(next.widgets)
2092
2080
  setLocalSources(next.sources)
2093
2081
  setLocalConnectors(next.connectors ?? [])
2094
2082
  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
- )
2083
+ updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
2084
+ .catch((err) => console.error('[canvas] Failed to persist redo:', err))
2085
+ .finally(() => { dirtyRef.current = false })
2098
2086
  )
2099
2087
  }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
2100
2088
 
@@ -2386,27 +2374,31 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2386
2374
  return aSelected - bSelected
2387
2375
  })
2388
2376
  for (const widget of sortedWidgets) {
2389
- if (!isLocalDev && widget.type === 'terminal') continue
2377
+ // In production, render terminal widgets as read-only instead of hiding them
2378
+ const effectiveWidget = (!isLocalDev && (widget.type === 'terminal' || widget.type === 'agent'))
2379
+ ? { ...widget, type: 'terminal-read' }
2380
+ : widget
2390
2381
  allChildren.push(
2391
2382
  <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}
2383
+ key={effectiveWidget.id}
2384
+ id={effectiveWidget.id}
2385
+ data-tc-x={effectiveWidget?.position?.x ?? 0}
2386
+ data-tc-y={effectiveWidget?.position?.y ?? 0}
2396
2387
  {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
2397
2388
  {...canvasPrimerAttrs}
2398
2389
  style={canvasThemeVars}
2399
2390
  onClick={isLocalDev ? (e) => {
2400
2391
  e.stopPropagation()
2401
2392
  if (!e.target.closest('.tc-drag-handle')) {
2402
- handleWidgetSelect(widget.id, e.shiftKey)
2393
+ handleWidgetSelect(effectiveWidget.id, e.shiftKey)
2403
2394
  }
2404
2395
  } : undefined}
2405
2396
  >
2406
2397
  <ChromeWrappedWidget
2407
- widget={widget}
2398
+ widget={effectiveWidget}
2408
2399
  selected={selectedWidgetIds.has(widget.id)}
2409
2400
  multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
2401
+ connectorCount={localConnectors.filter((c) => c.start?.widgetId === widget.id || c.end?.widgetId === widget.id).length}
2410
2402
  onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
2411
2403
  onDeselect={handleDeselectAll}
2412
2404
  onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
@@ -2423,19 +2415,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2423
2415
 
2424
2416
  const scale = zoom / 100
2425
2417
 
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
2418
+ const filteredConnectors = localConnectors
2433
2419
 
2434
2420
  return (
2435
2421
  <>
2436
2422
  <div className={styles.canvasTitle}>
2437
2423
  <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" />
2424
+ <Icon name="home" size={16} color="#fff" />
2439
2425
  </a>
2440
2426
  <CanvasTitleEditable
2441
2427
  canvasId={canvasId}
@@ -2444,9 +2430,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2444
2430
  isLocalDev={isLocalDev}
2445
2431
  />
2446
2432
  <PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
2447
- {isLocalDev && (
2448
- <span className={styles.localEditingLabel}>Local editing</span>
2449
- )}
2450
2433
  </div>
2451
2434
  <div
2452
2435
  ref={scrollRef}
@@ -2478,7 +2461,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2478
2461
  connectors={filteredConnectors}
2479
2462
  widgets={localWidgets ?? []}
2480
2463
  onRemove={isLocalDev ? handleConnectorRemove : undefined}
2481
- onEndpointDrag={isLocalDev ? handleEndpointDrag : undefined}
2464
+ onEndpointDrag={undefined}
2482
2465
  dragPreview={connectorDrag}
2483
2466
  hidden={widgetDragging}
2484
2467
  />
@@ -109,21 +109,6 @@
109
109
  isolation: isolate;
110
110
  }
111
111
 
112
- .localEditingLabel {
113
- display: inline-flex;
114
- align-items: center;
115
- padding: 4px 12px;
116
- background: hsl(212, 92%, 45%);
117
- color: #fff;
118
- font-size: 13px;
119
- font-weight: 600;
120
- border-radius: 6px;
121
- letter-spacing: 0.01em;
122
- white-space: nowrap;
123
- pointer-events: none;
124
- user-select: none;
125
- }
126
-
127
112
  .ghInstallBanner {
128
113
  position: fixed;
129
114
  left: 50%;
@@ -89,12 +89,16 @@ vi.mock('./widgets/widgetProps.js', () => ({
89
89
  getDefaults: () => ({}),
90
90
  }))
91
91
 
92
- vi.mock('./widgets/widgetConfig.js', () => ({
93
- getFeatures: () => [],
94
- isResizable: () => false,
95
- schemas: {},
96
- getMenuWidgetTypes: () => [],
97
- }))
92
+ vi.mock('./widgets/widgetConfig.js', async () => {
93
+ const actual = await vi.importActual('./widgets/widgetConfig.js')
94
+ return {
95
+ getFeatures: () => [],
96
+ isResizable: () => false,
97
+ schemas: {},
98
+ getMenuWidgetTypes: () => [],
99
+ getConnectorDefaults: actual.getConnectorDefaults,
100
+ }
101
+ })
98
102
 
99
103
  vi.mock('./widgets/figmaUrl.js', () => ({
100
104
  isFigmaUrl: () => false,