@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
@@ -112,35 +112,35 @@ function buildPath(startPt, startAnchor, endPt, endAnchor, freeEnd = false) {
112
112
  * @param {Object} endPt — position of the connector's end endpoint
113
113
  */
114
114
  function EndpointShape({ x, y, startPt, endPt, style, onPointerDown }) {
115
+ const passThrough = !onPointerDown ? { pointerEvents: 'none' } : {}
115
116
  if (style === 'none') {
116
117
  return (
117
118
  <circle cx={x} cy={y} r={connectorConfig.endpointRadius}
118
- style={{ fill: 'transparent', stroke: 'none', pointerEvents: 'auto', cursor: 'crosshair' }}
119
+ style={{ fill: 'transparent', stroke: 'none', ...(onPointerDown ? { pointerEvents: 'auto', cursor: 'crosshair' } : { pointerEvents: 'none' }) }}
119
120
  onPointerDown={onPointerDown}
120
121
  />
121
122
  )
122
123
  }
123
124
  if (style === 'arrow-start' || style === 'arrow-end') {
124
125
  const size = connectorConfig.endpointRadius * 2.2
125
- // Determine which point the arrow should aim toward
126
126
  const target = style === 'arrow-start' ? startPt : endPt
127
127
  const dx = target.x - x
128
128
  const dy = target.y - y
129
- // atan2 gives angle from positive X axis; polygon tip points up (-Y), so offset by 90°
130
129
  const rotation = (Math.atan2(dy, dx) * 180 / Math.PI) + 90
131
130
  return (
132
131
  <polygon
133
132
  points={`0,${-size} ${size * 0.6},${size * 0.5} ${-size * 0.6},${size * 0.5}`}
134
133
  transform={`translate(${x},${y}) rotate(${rotation})`}
135
134
  className={styles.connectorEndpoint}
135
+ style={passThrough}
136
136
  onPointerDown={onPointerDown}
137
137
  />
138
138
  )
139
139
  }
140
- // Default: circle
141
140
  return (
142
141
  <circle cx={x} cy={y} r={connectorConfig.endpointRadius}
143
142
  className={styles.connectorEndpoint}
143
+ style={passThrough}
144
144
  onPointerDown={onPointerDown}
145
145
  />
146
146
  )
@@ -216,7 +216,7 @@ export default function ConnectorLayer({
216
216
  className={styles.connectorPath}
217
217
  onClick={(e) => handleClick(e, conn.id)}
218
218
  />
219
- {/* Endpoint shapes — draggable to reconnect or remove */}
219
+ {/* Endpoint shapes — visual only, pointer events pass through to anchor dots */}
220
220
  <EndpointShape x={startPt.x} y={startPt.y} startPt={startPt} endPt={endPt} style={startStyle}
221
221
  onPointerDown={onEndpointDrag ? (e) => { e.stopPropagation(); e.preventDefault(); onEndpointDrag(conn, 'start', e) } : undefined}
222
222
  />
@@ -1,5 +1,5 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
2
- import { render, screen, fireEvent } from '@testing-library/react'
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
3
3
  import PageSelector from './PageSelector.jsx'
4
4
 
5
5
  const PAGES = [
@@ -10,9 +10,15 @@ const PAGES = [
10
10
 
11
11
  describe('PageSelector', () => {
12
12
  beforeEach(() => {
13
- // Reset location mock
13
+ // Reset location mock — use a setter spy so we can track assignments
14
14
  delete window.location
15
- window.location = { href: '' }
15
+ const loc = { _href: '' }
16
+ Object.defineProperty(loc, 'href', {
17
+ get() { return loc._href },
18
+ set(v) { loc._href = v },
19
+ configurable: true,
20
+ })
21
+ window.location = loc
16
22
  })
17
23
 
18
24
  it('renders nothing when fewer than 2 pages', () => {
@@ -58,14 +64,17 @@ describe('PageSelector', () => {
58
64
  expect(options[0].getAttribute('aria-selected')).toBe('false')
59
65
  })
60
66
 
61
- it('navigates to selected page', () => {
67
+ it('navigates to selected page', async () => {
62
68
  render(<PageSelector currentName="research/interviews" pages={PAGES} />)
63
69
  fireEvent.click(screen.getByTitle('Switch canvas page'))
64
70
  // Click the option in the menu (not the trigger label)
65
71
  const options = screen.getAllByRole('option')
66
72
  fireEvent.click(options[1]) // Surveys
67
73
 
68
- expect(window.location.href).toContain('/canvas/research/surveys')
74
+ // Navigation uses a 300ms setTimeout for mouse clicks
75
+ await waitFor(() => {
76
+ expect(window.location.href).toContain('/canvas/research/surveys')
77
+ })
69
78
  })
70
79
 
71
80
  it('closes dropdown on Escape', () => {
@@ -100,7 +100,7 @@ export function useCanvas(canvasId) {
100
100
  useEffect(() => {
101
101
  if (!import.meta.hot || !buildTimeCanvas) return
102
102
 
103
- const handleCanvasFileChanged = ({ data }) => {
103
+ const handleCanvasFileChanged = (data) => {
104
104
  const eventId = data?.canvasId || data?.name
105
105
  if (!data || eventId !== canvasId) return
106
106
  // Use metadata from the HMR event directly if available (faster)
@@ -0,0 +1,200 @@
1
+ import { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'
2
+ import { readProp } from './widgetProps.js'
3
+ import { schemas } from './widgetProps.js'
4
+ import ResizeHandle from './ResizeHandle.jsx'
5
+ import styles from './ActionWidget.module.css'
6
+
7
+ const actionSchema = schemas['action']
8
+
9
+ /**
10
+ * ActionWidget — a canvas widget that runs a background agent.
11
+ *
12
+ * Displays a "Run" button. When clicked, spawns a headless tmux+copilot
13
+ * session via the /agent/spawn endpoint. Shows status indicators
14
+ * (running/done/error) and allows peeking into errored sessions.
15
+ */
16
+ export default forwardRef(function ActionWidget({ id, props, onUpdate, resizable }, ref) {
17
+ const width = readProp(props, 'width', actionSchema)
18
+ const height = readProp(props, 'height', actionSchema)
19
+ const prompt = readProp(props, 'prompt', actionSchema) || ''
20
+ const label = readProp(props, 'label', actionSchema) || 'Run Agent'
21
+
22
+ const [status, setStatus] = useState('idle') // idle | running | done | error
23
+ const [message, setMessage] = useState(null)
24
+
25
+ useImperativeHandle(ref, () => ({
26
+ handleAction(actionId) {
27
+ // ActionWidget doesn't handle expand/split-screen itself
28
+ return false
29
+ },
30
+ }), [])
31
+
32
+ // Listen for agent status updates via Vite HMR custom events
33
+ useEffect(() => {
34
+ if (!import.meta.hot) return
35
+
36
+ const handler = (data) => {
37
+ if (data.widgetId === id) {
38
+ setStatus(data.status)
39
+ setMessage(data.message || null)
40
+ }
41
+ }
42
+
43
+ import.meta.hot.on('storyboard:agent-status', handler)
44
+ return () => {
45
+ // Vite HMR doesn't support removeListener, but cleanup on unmount
46
+ }
47
+ }, [id])
48
+
49
+ // Poll for status on mount (in case we missed a WS event)
50
+ useEffect(() => {
51
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
52
+ const baseClean = base.endsWith('/') ? base : base + '/'
53
+
54
+ fetch(`${baseClean}_storyboard/canvas/agent/status?widgetId=${id}`)
55
+ .then((r) => r.json())
56
+ .then((data) => {
57
+ if (data.agentStatus?.status) {
58
+ setStatus(data.agentStatus.status)
59
+ setMessage(data.agentStatus.message || null)
60
+ }
61
+ })
62
+ .catch(() => {})
63
+ }, [id])
64
+
65
+ const handleRun = useCallback(async () => {
66
+ if (status === 'running') return
67
+
68
+ setStatus('running')
69
+ setMessage('Spawning agent...')
70
+
71
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
72
+ const baseClean = base.endsWith('/') ? base : base + '/'
73
+
74
+ try {
75
+ const res = await fetch(`${baseClean}_storyboard/canvas/agent/spawn`, {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json' },
78
+ body: JSON.stringify({
79
+ canvasId: window.__storyboardCanvasBridgeState?.canvasId || 'unknown',
80
+ widgetId: id,
81
+ prompt,
82
+ autopilot: true,
83
+ }),
84
+ })
85
+
86
+ if (!res.ok) {
87
+ const data = await res.json().catch(() => ({}))
88
+ setStatus('error')
89
+ setMessage(data.error || 'Spawn failed')
90
+ }
91
+ } catch (err) {
92
+ setStatus('error')
93
+ setMessage(err.message || 'Connection failed')
94
+ }
95
+ }, [id, prompt, status])
96
+
97
+ const handlePeek = useCallback(async () => {
98
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
99
+ const baseClean = base.endsWith('/') ? base : base + '/'
100
+
101
+ try {
102
+ const res = await fetch(`${baseClean}_storyboard/canvas/agent/peek`, {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({
106
+ widgetId: id,
107
+ canvasId: window.__storyboardCanvasBridgeState?.canvasId || 'unknown',
108
+ }),
109
+ })
110
+
111
+ if (res.ok) {
112
+ setMessage('Session opened — check the new terminal widget')
113
+ } else {
114
+ const data = await res.json().catch(() => ({}))
115
+ setMessage(data.error || 'Peek failed')
116
+ }
117
+ } catch (err) {
118
+ setMessage(err.message || 'Connection failed')
119
+ }
120
+ }, [id])
121
+
122
+ const handleDismiss = useCallback(() => {
123
+ setStatus('idle')
124
+ setMessage(null)
125
+ }, [])
126
+
127
+ const handleResize = useCallback((w, h) => {
128
+ onUpdate?.({ width: w, height: h })
129
+ }, [onUpdate])
130
+
131
+ const statusIcon = {
132
+ idle: '⚡',
133
+ running: '⏳',
134
+ done: '✓',
135
+ error: '!',
136
+ }
137
+
138
+ const statusClass = {
139
+ idle: styles.idle,
140
+ running: styles.running,
141
+ done: styles.done,
142
+ error: styles.error,
143
+ }
144
+
145
+ return (
146
+ <div
147
+ className={`${styles.container} ${statusClass[status] || ''}`}
148
+ style={{
149
+ ...(typeof width === 'number' ? { width: `${width}px` } : undefined),
150
+ ...(typeof height === 'number' ? { height: `${height}px` } : undefined),
151
+ }}
152
+ >
153
+ <div className={styles.header}>
154
+ <span className={styles.icon}>{statusIcon[status]}</span>
155
+ <span className={styles.label}>{label}</span>
156
+ </div>
157
+
158
+ {prompt && (
159
+ <div className={styles.prompt}>
160
+ {prompt.length > 100 ? prompt.slice(0, 100) + '…' : prompt}
161
+ </div>
162
+ )}
163
+
164
+ <div className={styles.actions}>
165
+ {(status === 'idle' || status === 'done') && (
166
+ <button className={styles.runButton} onClick={handleRun}>
167
+ {status === 'done' ? 'Run Again' : 'Run'}
168
+ </button>
169
+ )}
170
+
171
+ {status === 'running' && (
172
+ <div className={styles.spinner}>Running…</div>
173
+ )}
174
+
175
+ {status === 'error' && (
176
+ <div className={styles.errorActions}>
177
+ <button className={styles.peekButton} onClick={handlePeek}>
178
+ Peek Session
179
+ </button>
180
+ <button className={styles.dismissButton} onClick={handleDismiss}>
181
+ Dismiss
182
+ </button>
183
+ </div>
184
+ )}
185
+ </div>
186
+
187
+ {message && (
188
+ <div className={styles.message}>{message}</div>
189
+ )}
190
+
191
+ {resizable && (
192
+ <ResizeHandle
193
+ onResize={handleResize}
194
+ minWidth={200}
195
+ minHeight={120}
196
+ />
197
+ )}
198
+ </div>
199
+ )
200
+ })
@@ -0,0 +1,122 @@
1
+ .container {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 8px;
5
+ padding: 16px;
6
+ border-radius: 8px;
7
+ background: var(--bgColor-default, #0d1117);
8
+ border: 1px solid var(--borderColor-default, #30363d);
9
+ color: var(--fgColor-default, #e6edf3);
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
11
+ font-size: 13px;
12
+ overflow: hidden;
13
+ }
14
+
15
+ .container.running {
16
+ border-color: var(--borderColor-accent-emphasis, #58a6ff);
17
+ }
18
+
19
+ .container.done {
20
+ border-color: var(--borderColor-success-emphasis, #3fb950);
21
+ }
22
+
23
+ .container.error {
24
+ border-color: var(--borderColor-danger-emphasis, #f85149);
25
+ }
26
+
27
+ .header {
28
+ display: flex;
29
+ align-items: center;
30
+ gap: 8px;
31
+ font-weight: 600;
32
+ font-size: 14px;
33
+ }
34
+
35
+ .icon {
36
+ font-size: 16px;
37
+ }
38
+
39
+ .label {
40
+ flex: 1;
41
+ overflow: hidden;
42
+ text-overflow: ellipsis;
43
+ white-space: nowrap;
44
+ }
45
+
46
+ .prompt {
47
+ color: var(--fgColor-muted, #8b949e);
48
+ font-size: 12px;
49
+ line-height: 1.4;
50
+ overflow: hidden;
51
+ display: -webkit-box;
52
+ -webkit-line-clamp: 3;
53
+ -webkit-box-orient: vertical;
54
+ }
55
+
56
+ .actions {
57
+ display: flex;
58
+ gap: 8px;
59
+ margin-top: auto;
60
+ }
61
+
62
+ .runButton {
63
+ padding: 6px 16px;
64
+ border-radius: 6px;
65
+ border: none;
66
+ background: var(--bgColor-accent-emphasis, #1f6feb);
67
+ color: #fff;
68
+ font-size: 13px;
69
+ font-weight: 500;
70
+ cursor: pointer;
71
+ transition: background 0.15s;
72
+ }
73
+
74
+ .runButton:hover {
75
+ background: var(--bgColor-accent-emphasis, #388bfd);
76
+ }
77
+
78
+ .spinner {
79
+ color: var(--fgColor-accent, #58a6ff);
80
+ font-size: 12px;
81
+ }
82
+
83
+ .errorActions {
84
+ display: flex;
85
+ gap: 8px;
86
+ }
87
+
88
+ .peekButton {
89
+ padding: 4px 12px;
90
+ border-radius: 6px;
91
+ border: 1px solid var(--borderColor-danger-emphasis, #f85149);
92
+ background: transparent;
93
+ color: var(--fgColor-danger, #f85149);
94
+ font-size: 12px;
95
+ cursor: pointer;
96
+ }
97
+
98
+ .peekButton:hover {
99
+ background: var(--bgColor-danger-muted, rgba(248, 81, 73, 0.1));
100
+ }
101
+
102
+ .dismissButton {
103
+ padding: 4px 12px;
104
+ border-radius: 6px;
105
+ border: 1px solid var(--borderColor-default, #30363d);
106
+ background: transparent;
107
+ color: var(--fgColor-muted, #8b949e);
108
+ font-size: 12px;
109
+ cursor: pointer;
110
+ }
111
+
112
+ .dismissButton:hover {
113
+ background: var(--bgColor-muted, #161b22);
114
+ }
115
+
116
+ .message {
117
+ color: var(--fgColor-muted, #8b949e);
118
+ font-size: 11px;
119
+ overflow: hidden;
120
+ text-overflow: ellipsis;
121
+ white-space: nowrap;
122
+ }
@@ -5,6 +5,8 @@ import { readProp } from './widgetProps.js'
5
5
  import { schemas } from './widgetConfig.js'
6
6
  import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
7
7
  import { useIframeDevLogs } from './iframeDevLogs.js'
8
+ import { findConnectedSplitTarget, getPaneOrder, buildSecondaryIframeUrl, reparentTerminalInto, getSplitPaneLabel } from './expandUtils.js'
9
+ import SplitScreenTopBar from './SplitScreenTopBar.jsx'
8
10
  import styles from './FigmaEmbed.module.css'
9
11
  import overlayStyles from './embedOverlay.module.css'
10
12
 
@@ -49,7 +51,7 @@ function FigmaLogo() {
49
51
 
50
52
  const TYPE_LABELS = { board: 'Board', design: 'Design', proto: 'Prototype' }
51
53
 
52
- export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, ref) {
54
+ export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
53
55
  const url = readProp(props, 'url', figmaEmbedSchema)
54
56
  const width = readProp(props, 'width', figmaEmbedSchema)
55
57
  const height = readProp(props, 'height', figmaEmbedSchema)
@@ -156,7 +158,7 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
156
158
  handleAction(actionId) {
157
159
  if (actionId === 'open-external') {
158
160
  if (url) window.open(url, '_blank', 'noopener')
159
- } else if (actionId === 'expand') {
161
+ } else if (actionId === 'expand' || actionId === 'split-screen') {
160
162
  setShowIframe(true)
161
163
  setExpanded(true)
162
164
  }
@@ -247,35 +249,101 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
247
249
  )}
248
250
  </WidgetWrapper>
249
251
  {createPortal(
250
- <div
251
- className={styles.expandBackdrop}
252
- style={expanded && embedUrl ? undefined : { display: 'none' }}
253
- onClick={() => setExpanded(false)}
254
- onPointerDown={(e) => e.stopPropagation()}
255
- onKeyDown={(e) => {
256
- e.stopPropagation()
257
- if (e.key === 'Escape') setExpanded(false)
258
- }}
259
- onWheel={(e) => e.stopPropagation()}
260
- tabIndex={-1}
261
- ref={(el) => { if (el && expanded) el.focus() }}
262
- >
263
- <div
264
- ref={modalContainerRef}
265
- className={styles.expandContainer}
266
- onClick={(e) => e.stopPropagation()}
267
- >
268
- {/* iframe is reparented here via useEffect */}
269
- <button
270
- className={styles.expandClose}
271
- onClick={() => setExpanded(false)}
272
- aria-label="Close expanded view"
273
- autoFocus
274
- >✕</button>
275
- </div>
276
- </div>,
252
+ <FigmaExpandModal
253
+ expanded={expanded && !!embedUrl}
254
+ onClose={() => setExpanded(false)}
255
+ modalContainerRef={modalContainerRef}
256
+ widgetId={widgetId}
257
+ />,
277
258
  document.body
278
259
  )}
279
260
  </>
280
261
  )
281
262
  })
263
+
264
+ function FigmaExpandModal({ expanded, onClose, modalContainerRef, widgetId }) {
265
+ const connectedWidget = useMemo(
266
+ () => (expanded ? findConnectedSplitTarget(widgetId) : null),
267
+ [expanded, widgetId],
268
+ )
269
+ const hasSplit = Boolean(connectedWidget)
270
+ const paneOrder = useMemo(
271
+ () => (hasSplit ? getPaneOrder(widgetId, connectedWidget) : { primaryIsLeft: true }),
272
+ [hasSplit, widgetId, connectedWidget],
273
+ )
274
+ const secondaryUrl = useMemo(() => buildSecondaryIframeUrl(connectedWidget), [connectedWidget])
275
+ const isTerminalSecondary = connectedWidget?.type === 'terminal' || connectedWidget?.type === 'terminal-read' || connectedWidget?.type === 'agent'
276
+ const terminalRef = useRef(null)
277
+ const cleanupRef = useRef(null)
278
+ const [activePane, setActivePane] = useState('left')
279
+
280
+ const primaryWidget = useMemo(() => {
281
+ const bridge = window.__storyboardCanvasBridgeState
282
+ return bridge?.widgets?.find((w) => w.id === widgetId) || { type: 'figma-embed', props: {} }
283
+ }, [widgetId, expanded])
284
+
285
+ const primaryLabel = useMemo(() => getSplitPaneLabel(primaryWidget), [primaryWidget])
286
+ const secondaryLabel = useMemo(() => getSplitPaneLabel(connectedWidget), [connectedWidget])
287
+ const leftLabel = paneOrder.primaryIsLeft ? primaryLabel : secondaryLabel
288
+ const rightLabel = paneOrder.primaryIsLeft ? secondaryLabel : primaryLabel
289
+
290
+ useEffect(() => {
291
+ if (!isTerminalSecondary || !expanded || !terminalRef.current) return
292
+ cleanupRef.current = reparentTerminalInto(connectedWidget.id, terminalRef.current)
293
+ return () => { cleanupRef.current?.(); cleanupRef.current = null }
294
+ }, [isTerminalSecondary, expanded, connectedWidget?.id])
295
+
296
+ const primaryPane = (
297
+ <div
298
+ ref={modalContainerRef}
299
+ className={hasSplit ? styles.expandContainerSplit : styles.expandContainer}
300
+ onClick={(e) => e.stopPropagation()}
301
+ onPointerDown={() => setActivePane(paneOrder.primaryIsLeft ? 'left' : 'right')}
302
+ >
303
+ {!hasSplit && <button className={styles.expandClose} onClick={onClose} aria-label="Close expanded view" autoFocus>✕</button>}
304
+ </div>
305
+ )
306
+
307
+ let secondaryPane = null
308
+ const secondarySide = paneOrder.primaryIsLeft ? 'right' : 'left'
309
+ if (hasSplit) {
310
+ if (secondaryUrl) {
311
+ secondaryPane = <div className={styles.expandSecondary} onClick={(e) => e.stopPropagation()} onPointerDown={() => setActivePane(secondarySide)}><iframe src={secondaryUrl} className={styles.expandSecondaryIframe} title="Connected widget" /></div>
312
+ } else if (isTerminalSecondary) {
313
+ secondaryPane = <div className={styles.expandSecondary} onClick={(e) => e.stopPropagation()} onPointerDown={() => setActivePane(secondarySide)}><div ref={terminalRef} className={styles.expandTerminal} /></div>
314
+ }
315
+ }
316
+
317
+ const leftPane = paneOrder.primaryIsLeft ? primaryPane : secondaryPane
318
+ const rightPane = paneOrder.primaryIsLeft ? secondaryPane : primaryPane
319
+
320
+ return (
321
+ <div
322
+ className={styles.expandBackdrop}
323
+ style={expanded ? undefined : { display: 'none' }}
324
+ onClick={onClose}
325
+ onPointerDown={(e) => e.stopPropagation()}
326
+ onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Escape') onClose() }}
327
+ onWheel={(e) => e.stopPropagation()}
328
+ tabIndex={-1}
329
+ ref={(el) => { if (el && expanded) el.focus() }}
330
+ >
331
+ {hasSplit ? (
332
+ <div className={styles.expandSplitBody}>
333
+ <SplitScreenTopBar
334
+ leftLabel={leftLabel}
335
+ rightLabel={rightLabel}
336
+ activePane={activePane}
337
+ onClose={onClose}
338
+ />
339
+ <div className={styles.expandSplitPanes}>
340
+ <div className={styles.expandSplitLeft}>{leftPane}</div>
341
+ <div className={styles.expandSplitRight}>{rightPane}</div>
342
+ </div>
343
+ </div>
344
+ ) : (
345
+ primaryPane
346
+ )}
347
+ </div>
348
+ )
349
+ }
@@ -139,6 +139,67 @@
139
139
  background: rgba(0, 0, 0, 0.7);
140
140
  }
141
141
 
142
+ /* ── Split-screen layout ──────────────────────────────────────────── */
143
+
144
+ .expandContainerSplit {
145
+ flex: 1;
146
+ min-width: 0;
147
+ height: 100%;
148
+ position: relative;
149
+ overflow: hidden;
150
+ background: var(--bgColor-default, #ffffff);
151
+ }
152
+
153
+ .expandSplitBody {
154
+ position: fixed;
155
+ inset: 0;
156
+ display: flex;
157
+ flex-direction: column;
158
+ overflow: hidden;
159
+ animation: expandScaleIn 0.2s ease;
160
+ }
161
+
162
+ .expandSplitPanes {
163
+ flex: 1;
164
+ min-height: 0;
165
+ display: flex;
166
+ }
167
+
168
+ .expandSplitLeft {
169
+ flex: 1;
170
+ min-width: 0;
171
+ height: 100%;
172
+ overflow: hidden;
173
+ border-right: 1px solid var(--borderColor-muted, #d8dee4);
174
+ }
175
+
176
+ .expandSplitRight {
177
+ flex: 1;
178
+ min-width: 0;
179
+ height: 100%;
180
+ overflow: hidden;
181
+ }
182
+
183
+ .expandSecondary {
184
+ width: 100%;
185
+ height: 100%;
186
+ overflow: auto;
187
+ background: var(--bgColor-default, #ffffff);
188
+ }
189
+
190
+ .expandSecondaryIframe {
191
+ border: none;
192
+ width: 100%;
193
+ height: 100%;
194
+ display: block;
195
+ }
196
+
197
+ .expandTerminal {
198
+ width: 100%;
199
+ height: 100%;
200
+ background: #0d1117;
201
+ }
202
+
142
203
  .emptyState {
143
204
  width: 100%;
144
205
  height: calc(100% - 10px);
@@ -59,7 +59,7 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
59
59
  const url = getImageUrl(src)
60
60
  const a = document.createElement('a')
61
61
  a.href = url
62
- a.download = src.replace(/^_/, '')
62
+ a.download = src.replace(/^~/, '')
63
63
  document.body.appendChild(a)
64
64
  a.click()
65
65
  document.body.removeChild(a)