@dfosco/storyboard-react 4.2.0-beta.17 → 4.2.0-beta.19
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.
- package/package.json +7 -3
- package/src/BranchBar/BranchBar.jsx +3 -1
- package/src/BranchBar/BranchBar.module.css +2 -2
- package/src/BranchBar/useBranches.js +20 -6
- package/src/BranchBar/useBranches.test.js +68 -0
- package/src/CommandPalette/CommandPalette.jsx +250 -61
- package/src/CommandPalette/command-palette.css +12 -0
- package/src/Icon.jsx +46 -11
- package/src/Viewfinder.jsx +53 -133
- package/src/Viewfinder.module.css +20 -91
- package/src/Workspace.jsx +7 -0
- package/src/canvas/CanvasPage.jsx +601 -62
- package/src/canvas/CanvasPage.module.css +15 -2
- package/src/canvas/CanvasPage.multiselect.test.jsx +7 -0
- package/src/canvas/ConnectorLayer.jsx +120 -152
- package/src/canvas/ConnectorLayer.module.css +69 -0
- package/src/canvas/canvasApi.js +68 -2
- package/src/canvas/connectorGeometry.js +132 -0
- package/src/canvas/hotPoolDevLogs.js +25 -0
- package/src/canvas/useMarqueeSelect.js +30 -4
- package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
- package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
- package/src/canvas/widgets/ComponentWidget.jsx +1 -0
- package/src/canvas/widgets/CropOverlay.jsx +219 -0
- package/src/canvas/widgets/CropOverlay.module.css +118 -0
- package/src/canvas/widgets/ExpandedPane.jsx +472 -0
- package/src/canvas/widgets/ExpandedPane.module.css +179 -0
- package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +49 -102
- package/src/canvas/widgets/ImageWidget.jsx +129 -8
- package/src/canvas/widgets/ImageWidget.module.css +30 -0
- package/src/canvas/widgets/LinkPreview.jsx +93 -44
- package/src/canvas/widgets/MarkdownBlock.jsx +141 -16
- package/src/canvas/widgets/MarkdownBlock.module.css +25 -0
- package/src/canvas/widgets/PromptWidget.jsx +414 -0
- package/src/canvas/widgets/PromptWidget.module.css +273 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +46 -170
- package/src/canvas/widgets/ResizeHandle.jsx +17 -6
- package/src/canvas/widgets/StoryWidget.jsx +65 -11
- package/src/canvas/widgets/TerminalReadWidget.jsx +11 -5
- package/src/canvas/widgets/TerminalReadWidget.module.css +3 -1
- package/src/canvas/widgets/TerminalWidget.jsx +301 -124
- package/src/canvas/widgets/TerminalWidget.module.css +121 -12
- package/src/canvas/widgets/TilesWidget.jsx +302 -0
- package/src/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/canvas/widgets/WidgetChrome.jsx +67 -152
- package/src/canvas/widgets/WidgetChrome.module.css +20 -1
- package/src/canvas/widgets/expandUtils.js +385 -16
- package/src/canvas/widgets/expandUtils.test.js +155 -0
- package/src/canvas/widgets/index.js +6 -2
- package/src/canvas/widgets/tilePool.js +23 -0
- package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
- package/src/canvas/widgets/tiles/leaf.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
- package/src/canvas/widgets/tiles/solid-a.png +0 -0
- package/src/canvas/widgets/tiles/solid-b.png +0 -0
- package/src/canvas/widgets/widgetConfig.js +37 -4
- package/src/canvas/widgets/widgetIcons.jsx +190 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +47 -19
- package/src/hooks/usePrototypeReloadGuard.js +64 -0
- package/src/index.js +4 -2
- package/src/story/ComponentSetPage.jsx +186 -0
- package/src/story/ComponentSetPage.module.css +121 -0
- package/src/story/StoryPage.jsx +32 -2
- package/src/vite/data-plugin.js +79 -35
- package/src/canvas/widgets/ActionWidget.jsx +0 -200
- package/src/canvas/widgets/ActionWidget.module.css +0 -122
- package/src/canvas/widgets/SplitExpandModal.jsx +0 -234
- package/src/canvas/widgets/SplitExpandModal.module.css +0 -335
- package/src/canvas/widgets/SplitScreenTopBar.jsx +0 -30
- package/src/canvas/widgets/SplitScreenTopBar.module.css +0 -58
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { useRef, useEffect, useCallback, useState, useMemo, forwardRef, useImperativeHandle } from 'react'
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
3
2
|
import { readProp } from './widgetProps.js'
|
|
4
3
|
import { schemas } from './widgetProps.js'
|
|
5
|
-
import { getTerminalConfig } from '@dfosco/storyboard-core'
|
|
6
|
-
import { ScreenNormalIcon } from '@primer/octicons-react'
|
|
4
|
+
import { getTerminalConfig, getTerminalDimensions, getStoryData } from '@dfosco/storyboard-core'
|
|
7
5
|
import { useOverride } from '../../hooks/useOverride.js'
|
|
8
|
-
import { getSplitPaneLabel,
|
|
9
|
-
import
|
|
10
|
-
import ResizeHandle from './ResizeHandle.jsx'
|
|
6
|
+
import { getSplitPaneLabel, findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
|
|
7
|
+
import ExpandedPane from './ExpandedPane.jsx'
|
|
11
8
|
import styles from './TerminalWidget.module.css'
|
|
12
9
|
import overlayStyles from './embedOverlay.module.css'
|
|
13
10
|
|
|
@@ -30,6 +27,10 @@ function loadGhostty() {
|
|
|
30
27
|
return ghosttyPromise
|
|
31
28
|
}
|
|
32
29
|
|
|
30
|
+
// Global registry so split-screen can look up any terminal's ghostty + WS by widget ID
|
|
31
|
+
const terminalRegistry = new Map()
|
|
32
|
+
if (typeof window !== 'undefined') window.__storyboardTerminalRegistry = terminalRegistry
|
|
33
|
+
|
|
33
34
|
function getWsUrl(sessionId, prettyName, startupCommand) {
|
|
34
35
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
35
36
|
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
@@ -47,12 +48,37 @@ function calcDimensions(widthPx, heightPx, fontSize = 13) {
|
|
|
47
48
|
const scale = fontSize / 13
|
|
48
49
|
const cellWidth = 7.8 * scale
|
|
49
50
|
const cellHeight = 17 * scale
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
51
|
+
// .terminal has 16px padding + 1px border on each side (box-sizing: border-box)
|
|
52
|
+
const hPad = 34 // (16+1) * 2
|
|
53
|
+
const vPad = 34
|
|
54
|
+
const cols = Math.max(10, Math.floor((widthPx - hPad) / cellWidth))
|
|
55
|
+
const rows = Math.max(4, Math.floor((heightPx - vPad) / cellHeight))
|
|
53
56
|
return { cols, rows }
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Fit a terminal (by widget ID) to a container element using real cell metrics.
|
|
61
|
+
* Works for both the primary and secondary terminal in split-screen.
|
|
62
|
+
*/
|
|
63
|
+
function fitTerminalToElement(widgetId, containerEl) {
|
|
64
|
+
const entry = terminalRegistry.get(widgetId)
|
|
65
|
+
if (!entry || !containerEl) return
|
|
66
|
+
const { term, ws } = entry
|
|
67
|
+
const cw = term.renderer?.charWidth
|
|
68
|
+
const ch = term.renderer?.charHeight
|
|
69
|
+
if (!cw || !ch) return
|
|
70
|
+
const w = containerEl.clientWidth
|
|
71
|
+
const h = containerEl.clientHeight
|
|
72
|
+
// Skip if container hasn't laid out yet (flex: 1 may not have resolved)
|
|
73
|
+
if (w < 50 || h < 50) return
|
|
74
|
+
const cols = Math.max(10, Math.floor(w / cw))
|
|
75
|
+
const rows = Math.max(4, Math.floor(h / ch))
|
|
76
|
+
term.resize?.(cols, rows)
|
|
77
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
78
|
+
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
56
82
|
const EMBED_TYPES = new Set(['prototype', 'story'])
|
|
57
83
|
|
|
58
84
|
function findConnectedEmbed(widgetId) {
|
|
@@ -83,7 +109,7 @@ function buildEmbedUrl(widget) {
|
|
|
83
109
|
const storyId = widget.props?.storyId
|
|
84
110
|
const exportName = widget.props?.exportName
|
|
85
111
|
if (!storyId) return null
|
|
86
|
-
const storyData =
|
|
112
|
+
const storyData = getStoryData(storyId)
|
|
87
113
|
if (storyData?._route) {
|
|
88
114
|
const route = exportName ? `${storyData._route}?export=${exportName}` : storyData._route
|
|
89
115
|
return `${baseClean}${route}`
|
|
@@ -116,40 +142,67 @@ const DEFAULT_THEME = {
|
|
|
116
142
|
brightWhite: '#f0f6fc',
|
|
117
143
|
}
|
|
118
144
|
|
|
119
|
-
export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizable }, ref) {
|
|
145
|
+
export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizable, multiSelected }, ref) {
|
|
120
146
|
const cfg = getTerminalConfig()
|
|
121
147
|
const fontSize = cfg.fontSize ?? 13
|
|
122
|
-
const
|
|
123
|
-
const
|
|
148
|
+
const agentId = props?.agentId || null
|
|
149
|
+
const dims = getTerminalDimensions(agentId, {
|
|
150
|
+
width: readProp(props, 'width', terminalSchema),
|
|
151
|
+
height: readProp(props, 'height', terminalSchema),
|
|
152
|
+
})
|
|
153
|
+
const rawWidth = props?.width ?? dims.width
|
|
154
|
+
const rawHeight = props?.height ?? dims.height
|
|
124
155
|
const prettyName = props?.prettyName || null
|
|
125
156
|
const startupCommand = props?.startupCommand || null
|
|
126
157
|
|
|
158
|
+
// Snap dimensions to cell grid so the terminal fills its container exactly
|
|
159
|
+
const { cols, rows } = useMemo(
|
|
160
|
+
() => calcDimensions(rawWidth, rawHeight, fontSize),
|
|
161
|
+
[rawWidth, rawHeight, fontSize],
|
|
162
|
+
)
|
|
163
|
+
const width = rawWidth
|
|
164
|
+
const height = rawHeight
|
|
165
|
+
// Snapped dimensions computed from ghostty's actual cell metrics (set after open)
|
|
166
|
+
const [snappedHeight, setSnappedHeight] = useState(null)
|
|
167
|
+
const [snappedWidth, setSnappedWidth] = useState(null)
|
|
168
|
+
|
|
127
169
|
const containerRef = useRef(null)
|
|
128
170
|
const termRef = useRef(null)
|
|
129
171
|
const terminalRef = useRef(null)
|
|
130
172
|
const wsRef = useRef(null)
|
|
131
173
|
|
|
132
174
|
const [ready, setReady] = useState(false)
|
|
175
|
+
const [revealed, setRevealed] = useState(false)
|
|
133
176
|
const [error, setError] = useState(null)
|
|
134
177
|
const [sessionEnded, setSessionEnded] = useState(false)
|
|
135
178
|
const [connectAttempt, setConnectAttempt] = useState(0)
|
|
136
179
|
const [interactive, setInteractive] = useState(false)
|
|
137
180
|
const [expandedOverride, setExpandedOverride, clearExpandedOverride] = useOverride(`_terminal_expanded_${id}`)
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
181
|
+
const expandMode = expandedOverride === 'single' || expandedOverride === 'split' ? expandedOverride : expandedOverride === 'true' ? 'split' : null
|
|
182
|
+
const expanded = expandMode !== null
|
|
183
|
+
const setExpanded = useCallback((mode) => {
|
|
184
|
+
if (mode) setExpandedOverride(mode)
|
|
141
185
|
else clearExpandedOverride()
|
|
142
186
|
}, [setExpandedOverride, clearExpandedOverride])
|
|
143
187
|
const [waking, setWaking] = useState(false)
|
|
188
|
+
const [resourceLimited, setResourceLimited] = useState(null)
|
|
144
189
|
const [showDragHint, setShowDragHint] = useState(false)
|
|
145
190
|
const expandContainerRef = useRef(null)
|
|
146
191
|
const dragHintTimer = useRef(null)
|
|
147
192
|
|
|
148
193
|
useImperativeHandle(ref, () => ({
|
|
149
194
|
handleAction(actionId) {
|
|
150
|
-
if (actionId === 'expand'
|
|
195
|
+
if (actionId === 'expand') { setExpanded('single'); return true }
|
|
196
|
+
if (actionId === 'expand-single') { setExpanded('single'); return true }
|
|
197
|
+
if (actionId === 'split-screen') { setExpanded('split'); return true }
|
|
198
|
+
if (actionId === 'toggle-private') {
|
|
199
|
+
const isPrivate = !!props?.private
|
|
200
|
+
onUpdate?.({ private: !isPrivate })
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
return false
|
|
151
204
|
},
|
|
152
|
-
}), [setExpanded])
|
|
205
|
+
}), [setExpanded, props, onUpdate])
|
|
153
206
|
|
|
154
207
|
// Exit interactive on click outside
|
|
155
208
|
useEffect(() => {
|
|
@@ -165,9 +218,10 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
165
218
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
166
219
|
}, [interactive, id])
|
|
167
220
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
221
|
+
// Exit interactive when terminal becomes part of a multi-selection
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (multiSelected && interactive) setInteractive(false)
|
|
224
|
+
}, [multiSelected])
|
|
171
225
|
|
|
172
226
|
// Connect terminal + WebSocket
|
|
173
227
|
useEffect(() => {
|
|
@@ -202,6 +256,29 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
202
256
|
term.open(containerRef.current)
|
|
203
257
|
termRef.current = term
|
|
204
258
|
|
|
259
|
+
// Expose ghostty's actual computed cell metrics as CSS variables
|
|
260
|
+
// Set on .terminal (terminalRef) so they cascade into .xtermContainer
|
|
261
|
+
const cw = term.renderer?.charWidth
|
|
262
|
+
const ch = term.renderer?.charHeight
|
|
263
|
+
const wrap = terminalRef.current
|
|
264
|
+
if (wrap && cw) wrap.style.setProperty('--term-char-width', `${cw}px`)
|
|
265
|
+
if (wrap && ch) wrap.style.setProperty('--term-char-height', `${ch}px`)
|
|
266
|
+
if (wrap) {
|
|
267
|
+
wrap.style.setProperty('--term-cols', dims.cols)
|
|
268
|
+
wrap.style.setProperty('--term-rows', dims.rows)
|
|
269
|
+
wrap.style.setProperty('--term-font-size', `${cfg.fontSize ?? 13}px`)
|
|
270
|
+
const theme = { ...DEFAULT_THEME, ...cfg.theme }
|
|
271
|
+
wrap.style.setProperty('--term-bg', theme.background)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Snap container to exact cell grid using real metrics
|
|
275
|
+
// .terminal has 16px padding + 1px border on each side = 34px chrome per axis
|
|
276
|
+
if (!disposed) {
|
|
277
|
+
const pad = 34
|
|
278
|
+
if (ch) setSnappedHeight(Math.round(dims.rows * ch) + pad)
|
|
279
|
+
if (cw) setSnappedWidth(Math.round(dims.cols * cw) + pad)
|
|
280
|
+
}
|
|
281
|
+
|
|
205
282
|
// SGR mouse wheel for tmux scroll in alternate screen
|
|
206
283
|
term.attachCustomWheelEventHandler((e) => {
|
|
207
284
|
if (!(term.wasmTerm?.isAlternateScreen?.() ?? false)) return false
|
|
@@ -223,7 +300,6 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
223
300
|
ws.onopen = () => {
|
|
224
301
|
if (disposed) return
|
|
225
302
|
setReady(true)
|
|
226
|
-
setInteractive(true)
|
|
227
303
|
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
228
304
|
}
|
|
229
305
|
|
|
@@ -233,6 +309,11 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
233
309
|
if (data && data.startsWith('{')) {
|
|
234
310
|
try {
|
|
235
311
|
const msg = JSON.parse(data)
|
|
312
|
+
if (msg.type === 'resource-limited') {
|
|
313
|
+
setResourceLimited(msg)
|
|
314
|
+
setSessionEnded(true)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
236
317
|
if (msg.type === 'session-info' || msg.type === 'conflict' || msg.type === 'detached') return
|
|
237
318
|
} catch { /* not JSON */ }
|
|
238
319
|
}
|
|
@@ -254,6 +335,9 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
254
335
|
term.onData((data) => {
|
|
255
336
|
if (ws.readyState === WebSocket.OPEN) ws.send(data)
|
|
256
337
|
})
|
|
338
|
+
|
|
339
|
+
// Register in global registry for split-screen access
|
|
340
|
+
terminalRegistry.set(id, { term, ws })
|
|
257
341
|
} catch (err) {
|
|
258
342
|
if (!disposed) setError(err.message || 'Failed to load terminal')
|
|
259
343
|
}
|
|
@@ -263,6 +347,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
263
347
|
|
|
264
348
|
return () => {
|
|
265
349
|
disposed = true
|
|
350
|
+
terminalRegistry.delete(id)
|
|
266
351
|
if (ws && ws.readyState <= WebSocket.OPEN) ws.close()
|
|
267
352
|
if (term) term.dispose()
|
|
268
353
|
termRef.current = null
|
|
@@ -276,6 +361,20 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
276
361
|
const timer = setTimeout(() => {
|
|
277
362
|
const dims = calcDimensions(width, height, fontSize)
|
|
278
363
|
termRef.current?.resize?.(dims.cols, dims.rows)
|
|
364
|
+
// Update CSS variables after resize
|
|
365
|
+
const wrap = terminalRef.current
|
|
366
|
+
const cw = termRef.current?.renderer?.charWidth
|
|
367
|
+
const ch = termRef.current?.renderer?.charHeight
|
|
368
|
+
if (wrap && cw) wrap.style.setProperty('--term-char-width', `${cw}px`)
|
|
369
|
+
if (wrap && ch) wrap.style.setProperty('--term-char-height', `${ch}px`)
|
|
370
|
+
if (wrap) {
|
|
371
|
+
wrap.style.setProperty('--term-cols', dims.cols)
|
|
372
|
+
wrap.style.setProperty('--term-rows', dims.rows)
|
|
373
|
+
}
|
|
374
|
+
// Re-snap to cell grid
|
|
375
|
+
const pad = 34
|
|
376
|
+
if (ch) setSnappedHeight(Math.round(dims.rows * ch) + pad)
|
|
377
|
+
if (cw) setSnappedWidth(Math.round(dims.cols * cw) + pad)
|
|
279
378
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
280
379
|
wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
281
380
|
}
|
|
@@ -283,21 +382,32 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
283
382
|
return () => clearTimeout(timer)
|
|
284
383
|
}, [width, height])
|
|
285
384
|
|
|
286
|
-
//
|
|
385
|
+
// Reveal mask — hide terminal for 750ms after ready to mask startup flash
|
|
287
386
|
useEffect(() => {
|
|
288
|
-
if (!
|
|
387
|
+
if (!ready) {
|
|
388
|
+
setRevealed(false)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
289
391
|
const timer = setTimeout(() => {
|
|
290
|
-
|
|
291
|
-
if (!el) return
|
|
292
|
-
const dims = calcDimensions(el.clientWidth, el.clientHeight - 40, fontSize)
|
|
293
|
-
termRef.current?.resize?.(dims.cols, dims.rows)
|
|
294
|
-
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
295
|
-
wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
296
|
-
}
|
|
392
|
+
setRevealed(true)
|
|
297
393
|
setInteractive(true)
|
|
298
|
-
|
|
299
|
-
}, 100)
|
|
394
|
+
}, 750)
|
|
300
395
|
return () => clearTimeout(timer)
|
|
396
|
+
}, [ready])
|
|
397
|
+
|
|
398
|
+
const isAgent = id.startsWith('agent-')
|
|
399
|
+
const typeLabel = isAgent ? 'Agent' : 'Terminal'
|
|
400
|
+
const titleLabel = `${typeLabel} · ${prettyName || '…'}`
|
|
401
|
+
|
|
402
|
+
// Reparent terminal DOM between inline and expand container
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
const xtermEl = containerRef.current
|
|
405
|
+
if (!xtermEl) return
|
|
406
|
+
if (expanded && expandContainerRef.current) {
|
|
407
|
+
expandContainerRef.current.appendChild(xtermEl)
|
|
408
|
+
} else if (!expanded && terminalRef.current) {
|
|
409
|
+
terminalRef.current.appendChild(xtermEl)
|
|
410
|
+
}
|
|
301
411
|
}, [expanded])
|
|
302
412
|
|
|
303
413
|
// Restore size on collapse
|
|
@@ -313,20 +423,16 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
313
423
|
return () => clearTimeout(timer)
|
|
314
424
|
}, [expanded, width, height])
|
|
315
425
|
|
|
316
|
-
//
|
|
426
|
+
// Focus terminal on expand
|
|
317
427
|
useEffect(() => {
|
|
318
|
-
const xtermEl = containerRef.current
|
|
319
|
-
if (!xtermEl) return
|
|
320
428
|
if (expanded && expandContainerRef.current) {
|
|
321
|
-
expandContainerRef.current
|
|
322
|
-
} else if (!expanded && terminalRef.current) {
|
|
323
|
-
terminalRef.current.appendChild(xtermEl)
|
|
429
|
+
fitTerminalToElement(id, expandContainerRef.current)
|
|
324
430
|
}
|
|
325
431
|
}, [expanded])
|
|
326
432
|
|
|
327
433
|
const handleClick = useCallback(() => {
|
|
328
|
-
if (sessionEnded) return
|
|
329
|
-
if (
|
|
434
|
+
if (sessionEnded || multiSelected) return
|
|
435
|
+
if (revealed) {
|
|
330
436
|
setInteractive(true)
|
|
331
437
|
const scrollEl = terminalRef.current?.closest('[class*="canvasScroll"]')
|
|
332
438
|
const scrollTop = scrollEl?.scrollTop
|
|
@@ -337,7 +443,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
337
443
|
scrollEl.scrollLeft = scrollLeft
|
|
338
444
|
}
|
|
339
445
|
}
|
|
340
|
-
}, [sessionEnded,
|
|
446
|
+
}, [sessionEnded, multiSelected, revealed])
|
|
341
447
|
|
|
342
448
|
const handleTerminalPointerDown = useCallback((e) => {
|
|
343
449
|
if (!interactive) return
|
|
@@ -367,37 +473,50 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
367
473
|
setTimeout(() => {
|
|
368
474
|
setWaking(false)
|
|
369
475
|
setSessionEnded(false)
|
|
476
|
+
setResourceLimited(null)
|
|
370
477
|
setError(null)
|
|
371
478
|
setConnectAttempt(c => c + 1)
|
|
372
479
|
}, 1500)
|
|
373
480
|
}, [])
|
|
374
481
|
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
482
|
+
const handleCleanupAndRetry = useCallback(async () => {
|
|
483
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
484
|
+
const baseClean = base.endsWith('/') ? base : base + '/'
|
|
485
|
+
try {
|
|
486
|
+
const res = await fetch(`${baseClean}_storyboard/terminal/sessions/cleanup`, {
|
|
487
|
+
method: 'POST',
|
|
488
|
+
headers: { 'Content-Type': 'application/json' },
|
|
489
|
+
body: JSON.stringify({ statuses: ['archived', 'background'] }),
|
|
490
|
+
})
|
|
491
|
+
if (res.ok) {
|
|
492
|
+
const data = await res.json()
|
|
493
|
+
if (data.removed > 0) {
|
|
494
|
+
handleStartSession()
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
// Nothing was removed — update counts from server
|
|
498
|
+
setResourceLimited(prev => prev ? {
|
|
499
|
+
...prev,
|
|
500
|
+
cleanupResult: 'nothing-to-clean',
|
|
501
|
+
counts: data.remaining || prev.counts,
|
|
502
|
+
} : prev)
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
} catch { /* ignore fetch errors */ }
|
|
506
|
+
setResourceLimited(prev => prev ? { ...prev, cleanupResult: 'failed' } : prev)
|
|
507
|
+
}, [handleStartSession])
|
|
380
508
|
|
|
381
|
-
const paneOrder = useMemo(
|
|
382
|
-
() => (hasSplit ? getPaneOrder(id, connectedEmbed) : { primaryIsLeft: true }),
|
|
383
|
-
[hasSplit, id, connectedEmbed],
|
|
384
|
-
)
|
|
385
|
-
const primaryWidget = useMemo(() => ({ type: 'terminal', props: { prettyName } }), [prettyName])
|
|
386
|
-
const primaryLabel = useMemo(() => getSplitPaneLabel(primaryWidget), [primaryWidget])
|
|
387
|
-
const secondaryLabel = useMemo(() => getSplitPaneLabel(connectedEmbed), [connectedEmbed])
|
|
388
|
-
const leftLabel = paneOrder.primaryIsLeft ? primaryLabel : secondaryLabel
|
|
389
|
-
const rightLabel = paneOrder.primaryIsLeft ? secondaryLabel : primaryLabel
|
|
390
509
|
|
|
391
510
|
return (
|
|
392
511
|
<>
|
|
393
512
|
<div className={styles.container}>
|
|
394
|
-
<div className={styles.titleBar}>{titleLabel}</div>
|
|
513
|
+
<div className={`tc-drag-handle ${styles.titleBar}`}>{titleLabel}</div>
|
|
395
514
|
<div
|
|
396
515
|
ref={terminalRef}
|
|
397
516
|
className={styles.terminal}
|
|
398
517
|
style={{
|
|
399
|
-
...(typeof width === 'number' ? { width: `${width}px` } : undefined),
|
|
400
|
-
...(typeof height === 'number' ? { height: `${height}px` } : undefined),
|
|
518
|
+
...(typeof (snappedWidth ?? width) === 'number' ? { width: `${snappedWidth ?? width}px` } : undefined),
|
|
519
|
+
...(typeof (snappedHeight ?? height) === 'number' ? { height: `${snappedHeight ?? height}px` } : undefined),
|
|
401
520
|
}}
|
|
402
521
|
onClick={handleClick}
|
|
403
522
|
onPointerDown={handleTerminalPointerDown}
|
|
@@ -413,32 +532,71 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
413
532
|
<span>⚠ {error}</span>
|
|
414
533
|
</div>
|
|
415
534
|
)}
|
|
416
|
-
<div ref={containerRef} className={styles.xtermContainer} />
|
|
535
|
+
<div ref={containerRef} className={styles.xtermContainer} style={{ opacity: revealed ? 1 : 0 }} />
|
|
417
536
|
|
|
418
537
|
{/* Live but not interactive */}
|
|
419
|
-
{
|
|
538
|
+
{revealed && !interactive && !sessionEnded && (
|
|
420
539
|
<div
|
|
421
540
|
className={overlayStyles.interactOverlay}
|
|
422
541
|
style={{ backgroundColor: 'transparent' }}
|
|
423
542
|
onClick={(e) => {
|
|
424
|
-
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
543
|
+
if (multiSelected || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
425
544
|
setInteractive(true)
|
|
426
545
|
termRef.current?.focus({ preventScroll: true })
|
|
427
546
|
}}
|
|
428
547
|
role="button"
|
|
429
548
|
tabIndex={0}
|
|
430
|
-
onKeyDown={(e) => { if (e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
|
|
549
|
+
onKeyDown={(e) => { if (!multiSelected && e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
|
|
431
550
|
aria-label="Click to interact"
|
|
432
551
|
>
|
|
433
|
-
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
552
|
+
{!multiSelected && <span className={overlayStyles.interactHint}>Click to interact</span>}
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
|
|
556
|
+
{/* Session ended — resource limited */}
|
|
557
|
+
{sessionEnded && resourceLimited && (
|
|
558
|
+
<div
|
|
559
|
+
className={overlayStyles.interactOverlay}
|
|
560
|
+
style={{ backgroundColor: 'var(--term-bg, #0d1117)', flexDirection: 'column', gap: '8px', padding: '24px' }}
|
|
561
|
+
>
|
|
562
|
+
<span className={styles.resourceIcon}>⚠</span>
|
|
563
|
+
<span className={styles.resourceTitle}>No terminal devices available</span>
|
|
564
|
+
<span className={styles.resourceMessage}>
|
|
565
|
+
Too many terminal sessions are open.
|
|
566
|
+
{resourceLimited.counts && (
|
|
567
|
+
<span className={styles.resourceCounts}>
|
|
568
|
+
{resourceLimited.counts.live} live · {resourceLimited.counts.background} background · {resourceLimited.counts.archived} archived
|
|
569
|
+
</span>
|
|
570
|
+
)}
|
|
571
|
+
</span>
|
|
572
|
+
<div className={styles.resourceActions}>
|
|
573
|
+
{!resourceLimited.cleanupResult && (resourceLimited.counts?.background > 0 || resourceLimited.counts?.archived > 0) && (
|
|
574
|
+
<button className={styles.resourceBtn} onClick={handleCleanupAndRetry}>
|
|
575
|
+
Close background sessions
|
|
576
|
+
</button>
|
|
577
|
+
)}
|
|
578
|
+
{resourceLimited.cleanupResult === 'nothing-to-clean' && (
|
|
579
|
+
<span className={styles.resourceMuted}>
|
|
580
|
+
All background sessions already cleaned. Close some live terminals to free resources.
|
|
581
|
+
</span>
|
|
582
|
+
)}
|
|
583
|
+
{resourceLimited.cleanupResult === 'failed' && (
|
|
584
|
+
<span className={styles.resourceMuted}>
|
|
585
|
+
Cleanup failed — could not reach dev server.
|
|
586
|
+
</span>
|
|
587
|
+
)}
|
|
588
|
+
<button className={styles.resourceBtnSecondary} onClick={handleStartSession}>
|
|
589
|
+
Retry
|
|
590
|
+
</button>
|
|
591
|
+
</div>
|
|
434
592
|
</div>
|
|
435
593
|
)}
|
|
436
594
|
|
|
437
|
-
{/* Session ended */}
|
|
438
|
-
{sessionEnded && (
|
|
595
|
+
{/* Session ended — normal (zzz) */}
|
|
596
|
+
{sessionEnded && !resourceLimited && (
|
|
439
597
|
<div
|
|
440
598
|
className={overlayStyles.interactOverlay}
|
|
441
|
-
style={{ backgroundColor: '#0d1117', flexDirection: 'column', gap: 0 }}
|
|
599
|
+
style={{ backgroundColor: 'var(--term-bg, #0d1117)', flexDirection: 'column', gap: 0 }}
|
|
442
600
|
onClick={handleStartSession}
|
|
443
601
|
role="button"
|
|
444
602
|
tabIndex={0}
|
|
@@ -458,67 +616,86 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
458
616
|
</div>
|
|
459
617
|
)}
|
|
460
618
|
|
|
461
|
-
{/* Connecting */}
|
|
462
|
-
{!
|
|
463
|
-
<div className={styles.loading}>
|
|
619
|
+
{/* Connecting / reveal mask */}
|
|
620
|
+
{!revealed && !error && !sessionEnded && (
|
|
621
|
+
<div className={styles.loading}>
|
|
622
|
+
<div className={styles.spinner} />
|
|
623
|
+
</div>
|
|
464
624
|
)}
|
|
465
625
|
</div>
|
|
466
|
-
{resizable && (
|
|
467
|
-
<ResizeHandle
|
|
468
|
-
targetRef={terminalRef}
|
|
469
|
-
onResize={handleResize}
|
|
470
|
-
minWidth={300}
|
|
471
|
-
minHeight={200}
|
|
472
|
-
/>
|
|
473
|
-
)}
|
|
474
626
|
</div>
|
|
475
|
-
{
|
|
476
|
-
<
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
<SplitScreenTopBar
|
|
485
|
-
leftLabel={leftLabel}
|
|
486
|
-
rightLabel={rightLabel}
|
|
487
|
-
activePane={activePane}
|
|
488
|
-
onClose={() => setExpanded(false)}
|
|
489
|
-
/>
|
|
490
|
-
) : (
|
|
491
|
-
<div className={styles.expandTopBar}>
|
|
492
|
-
<span className={styles.expandTitle}>{titleLabel}</span>
|
|
493
|
-
<button className={styles.expandClose} onClick={() => setExpanded(false)} aria-label="Close expanded view" autoFocus><ScreenNormalIcon size={16} /></button>
|
|
494
|
-
</div>
|
|
495
|
-
)}
|
|
496
|
-
<div className={`${styles.expandBody}${hasSplit ? ` ${styles.expandSplit}` : ''}`}>
|
|
497
|
-
{hasSplit ? (
|
|
498
|
-
<>
|
|
499
|
-
{paneOrder.primaryIsLeft ? (
|
|
500
|
-
<>
|
|
501
|
-
<div ref={expandContainerRef} className={styles.expandTerminal} onPointerDown={() => setActivePane('left')} />
|
|
502
|
-
<div className={styles.expandEmbed} onPointerDown={() => setActivePane('right')}>
|
|
503
|
-
{embedUrl ? <iframe src={embedUrl} className={styles.expandIframe} title="Connected embed" /> : null}
|
|
504
|
-
</div>
|
|
505
|
-
</>
|
|
506
|
-
) : (
|
|
507
|
-
<>
|
|
508
|
-
<div className={styles.expandEmbed} onPointerDown={() => setActivePane('left')}>
|
|
509
|
-
{embedUrl ? <iframe src={embedUrl} className={styles.expandIframe} title="Connected embed" /> : null}
|
|
510
|
-
</div>
|
|
511
|
-
<div ref={expandContainerRef} className={styles.expandTerminal} onPointerDown={() => setActivePane('right')} />
|
|
512
|
-
</>
|
|
513
|
-
)}
|
|
514
|
-
</>
|
|
515
|
-
) : (
|
|
516
|
-
<div ref={expandContainerRef} className={styles.expandTerminal} />
|
|
517
|
-
)}
|
|
518
|
-
</div>
|
|
519
|
-
</div>,
|
|
520
|
-
document.body
|
|
627
|
+
{expanded && (
|
|
628
|
+
<TerminalExpandPane
|
|
629
|
+
widgetId={id}
|
|
630
|
+
expandContainerRef={expandContainerRef}
|
|
631
|
+
prettyName={prettyName}
|
|
632
|
+
isAgent={isAgent}
|
|
633
|
+
splitMode={expandMode === 'split'}
|
|
634
|
+
onClose={() => setExpanded(null)}
|
|
635
|
+
/>
|
|
521
636
|
)}
|
|
522
637
|
</>
|
|
523
638
|
)
|
|
524
639
|
})
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Builds pane configs and renders ExpandedPane for an expanded terminal widget.
|
|
643
|
+
* Primary pane is an external pane that receives the xterm container + handles fit.
|
|
644
|
+
*/
|
|
645
|
+
function TerminalExpandPane({ widgetId, expandContainerRef, prettyName, isAgent, splitMode, onClose }) {
|
|
646
|
+
const connectedWidgets = useMemo(
|
|
647
|
+
() => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
|
|
648
|
+
[widgetId, splitMode],
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
const primaryWidget = useMemo(() => {
|
|
652
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
653
|
+
return bridge?.widgets?.find((w) => w.id === widgetId) || {
|
|
654
|
+
id: widgetId,
|
|
655
|
+
type: isAgent ? 'agent' : 'terminal',
|
|
656
|
+
position: { x: 0, y: 0 },
|
|
657
|
+
props: { prettyName },
|
|
658
|
+
}
|
|
659
|
+
}, [widgetId, isAgent, prettyName])
|
|
660
|
+
|
|
661
|
+
// Custom pane builder for the primary widget (external pane with terminal fit)
|
|
662
|
+
const buildPaneFn = useCallback((widget) => {
|
|
663
|
+
if (widget.id === widgetId) {
|
|
664
|
+
return {
|
|
665
|
+
id: widgetId,
|
|
666
|
+
label: getSplitPaneLabel({ type: isAgent ? 'agent' : 'terminal', props: { prettyName } }),
|
|
667
|
+
widgetType: isAgent ? 'agent' : 'terminal',
|
|
668
|
+
kind: 'external',
|
|
669
|
+
attach: (container) => {
|
|
670
|
+
expandContainerRef.current = container
|
|
671
|
+
const t1 = setTimeout(() => fitTerminalToElement(widgetId, container), 150)
|
|
672
|
+
const t2 = setTimeout(() => fitTerminalToElement(widgetId, container), 400)
|
|
673
|
+
return () => {
|
|
674
|
+
clearTimeout(t1)
|
|
675
|
+
clearTimeout(t2)
|
|
676
|
+
expandContainerRef.current = null
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
onResize: () => {
|
|
680
|
+
if (expandContainerRef.current) {
|
|
681
|
+
fitTerminalToElement(widgetId, expandContainerRef.current)
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return buildPaneForWidget(widget)
|
|
687
|
+
}, [widgetId, prettyName, isAgent, expandContainerRef])
|
|
688
|
+
|
|
689
|
+
const layout = useMemo(
|
|
690
|
+
() => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
|
|
691
|
+
[primaryWidget, connectedWidgets, buildPaneFn],
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
return (
|
|
695
|
+
<ExpandedPane
|
|
696
|
+
initialLayout={layout}
|
|
697
|
+
variant="full"
|
|
698
|
+
onClose={onClose}
|
|
699
|
+
/>
|
|
700
|
+
)
|
|
701
|
+
}
|